diff --git a/front/src/assets/integrations/cover/tessie.jpg b/front/src/assets/integrations/cover/tessie.jpg new file mode 100644 index 0000000000..2fb89b9a8b Binary files /dev/null and b/front/src/assets/integrations/cover/tessie.jpg differ diff --git a/front/src/assets/integrations/cover/tessie.jpg:Zone.Identifier b/front/src/assets/integrations/cover/tessie.jpg:Zone.Identifier new file mode 100644 index 0000000000..f46bcc4931 --- /dev/null +++ b/front/src/assets/integrations/cover/tessie.jpg:Zone.Identifier @@ -0,0 +1,2 @@ +[ZoneTransfer] +ZoneId=3 diff --git a/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-black.jpg b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-black.jpg new file mode 100644 index 0000000000..6e89a21bf2 Binary files /dev/null and b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-black.jpg differ diff --git a/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-blue.jpg b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-blue.jpg new file mode 100644 index 0000000000..86380398f2 Binary files /dev/null and b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-blue.jpg differ diff --git a/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-grey.jpg b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-grey.jpg new file mode 100644 index 0000000000..2a9ebb08cf Binary files /dev/null and b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-grey.jpg differ diff --git a/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-pearlwhite.jpg b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-pearlwhite.jpg new file mode 100644 index 0000000000..a55ac2140f Binary files /dev/null and b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-pearlwhite.jpg differ diff --git a/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-red.jpg b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-red.jpg new file mode 100644 index 0000000000..a286f749dc Binary files /dev/null and b/front/src/assets/integrations/devices/vehicle/tesla/modely/tesla-modely-red.jpg differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index a581170ca9..43896c99e5 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -142,6 +142,11 @@ 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'; +// Tessie integration +import TessiePage from '../routes/integration/all/tessie/device-page'; +import TessieSetupPage from '../routes/integration/all/tessie/setup-page'; +import TessieDiscoverPage from '../routes/integration/all/tessie/discover-page'; + // Sonos integration import SonosDevicePage from '../routes/integration/all/sonos/device-page'; import SonosDiscoveryPage from '../routes/integration/all/sonos/discover-page'; @@ -306,6 +311,10 @@ const AppRouter = connect( + + + + diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/PressureSensorDeviceValue.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/PressureSensorDeviceValue.jsx new file mode 100644 index 0000000000..16f02db049 --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/PressureSensorDeviceValue.jsx @@ -0,0 +1,24 @@ +import { Text } from 'preact-i18n'; +import { checkAndConvertUnit } from '../../../../../../../server/utils/units'; + +const PressureSensorDeviceValue = ({ deviceFeature, user }) => { + const { last_value: lastValue = null, unit } = deviceFeature; + const { distance_unit_preference: userUnitPreference } = user; + + // Convert the value to the user unit if needed + const { value: displayValue, unit: displayUnit } = checkAndConvertUnit(lastValue, unit, userUnitPreference); + + return ( +
+ {displayValue === null && } + {displayValue !== null && ( + + {`${displayValue} `} + + + )} +
+ ); +}; + +export default PressureSensorDeviceValue; diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx index 9c67b9965e..72f7a09318 100644 --- a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx @@ -17,6 +17,7 @@ import TextDeviceValue from './TextDeviceValue'; import NoRecentValueBadge from './NoRecentValueBadge'; import TemperatureSensorDeviceValue from './TemperatureSensorDeviceValue'; import LevelSensorDeviceValue from './LevelSensorDeviceValue'; +import PressureSensorDeviceValue from './PressureSensorDeviceValue'; const DISPLAY_BY_FEATURE_CATEGORY = { [DEVICE_FEATURE_CATEGORIES.MOTION_SENSOR]: MotionSensorDeviceValue, @@ -27,6 +28,7 @@ const DISPLAY_BY_FEATURE_CATEGORY = { [DEVICE_FEATURE_CATEGORIES.TEXT]: TextDeviceValue, [DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR]: TemperatureSensorDeviceValue, [DEVICE_FEATURE_CATEGORIES.DISTANCE_SENSOR]: DistanceSensorDeviceValue, + [DEVICE_FEATURE_CATEGORIES.PRESSURE_SENSOR]: PressureSensorDeviceValue, [DEVICE_FEATURE_CATEGORIES.SPEED_SENSOR]: DistanceSensorDeviceValue, [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_DRIVE]: DistanceSensorDeviceValue, [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_CONSUMPTION]: DistanceSensorDeviceValue @@ -45,8 +47,10 @@ const DISPLAY_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_TEMPERATURE]: TemperatureSensorDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_VOLTAGE]: BadgeNumberDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.INDOOR_TEMPERATURE]: TemperatureSensorDeviceValue, + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.OUTSIDE_TEMPERATURE]: TemperatureSensorDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_RANGE_ESTIMATE]: DistanceSensorDeviceValue, - [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER]: DistanceSensorDeviceValue + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER]: DistanceSensorDeviceValue, + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: PressureSensorDeviceValue }; const DEVICE_FEATURES_WITHOUT_EXPIRATION = [ diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 95a47bfbf0..7d48c0931d 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -193,7 +193,7 @@ "validationError": "Der Name des Zuhauses muss zwischen 1 und 40 Zeichen lang sein", "validationErrorRoom": "Der Raumname muss zwischen 1 und 40 Zeichen lang sein", "alarmTitle": "Alarm", - "alarmDescription": "Wenn du den Alarmmodus in Gladys verwendest, kannst du den Deaktivierungscode und die Verzögerung vor der Scharfschaltung des Alarms hier konfigurieren. Diese Verzögerung gilt nur im Fall einer manueller Auslösung und nicht in Szenen.", + "alarmDescription": "Wenn du den Alarmmodus in Gladys verwendest, kannst du den Deaktivierungscode und die Verzögerung vor der Scharfschaltung des Alarms hier konfigurieren. Diese Verzögerung gilt nur im Fall einer manuellen Auslösung und nicht in Szenen.", "alarmCodeLabel": "Deaktivierungscode", "alarmCodePlaceholder": "Gib einen numerischen Code zwischen 4 und 8 Ziffern ein", "alarmDelayBeforeArmingLabel": "Verzögerung vor Scharfschaltung des Alarms", @@ -2799,6 +2799,7 @@ "percent": "Prozent (%)", "pascal": "Pascal (Pa)", "hPa": "Hektopascal (hPa)", + "kPa": "Kilopascal (kPa)", "bar": "Bar", "psi": "Pound-force per square inch (psi)", "milli-bar": "Millibar (mbar)", @@ -2888,6 +2889,7 @@ "percent": "%", "pascal": "Pa", "hPa": "hPa", + "kPa": "kPa", "bar": "bar", "milli-bar": "mbar", "psi": "psi", @@ -3423,6 +3425,7 @@ "shortCategoryName": "Elektrofahrzeug - Klima", "climate-on": "Klimaanlagenschalter", "indoor-temperature": "Innentemperatur", + "outside-temperature": "Außentemperatur", "target-temperature": "Zieltemperatur" }, "electrical-vehicle-command": { @@ -3444,6 +3447,7 @@ "shortCategoryName": "Elektrofahrzeug - Status", "door-opened": "Tür geöffnet", "odometer": "Kilometerzähler", + "tire-pressure": "Reifendruck", "window-opened": "Fenster geöffnet" }, "heater": { @@ -3860,4 +3864,4 @@ "upgradeYearlyError": "Beim Wechsel in das jährliche Abomodell ist ein Fehler aufgetreten. Bitte kontaktiere uns per E-Mail.", "upgradeYearlySuccess": "Vielen Dank, dass du in das jährliche Abmodell gewechselt bist!" } -} +} \ No newline at end of file diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 246692a56a..6c68432ba2 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -735,6 +735,93 @@ "conflictError": "Current topic is already in use." } }, + "tessie": { + "title": "Tessie", + "description": "Control Tessie devices", + "deviceTab": "Devices", + "documentation": "Tessie documentation", + "discoverDeviceDescr": "Automatically scan Tessie devices", + "status": { + "notConfigured": "The Tessie service is not configured", + "disconnect": "Gladys is not connected to Tessie", + "notConnected": "Gladys failed to connect to the Tessie account, please verify your credentials on the ", + "setupPageLink": "Tessie Setup Page.", + "connect": "Gladys is connected to Tessie", + "connecting": "Configuration saved. Connecting to your Tessie account...", + "processingToken": "Connecting to your Tessie 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 Tessie 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 Tessie account information and that the account is not banned or disabled My App." + }, + "weatherApiNotConfigured": "Please go to the \"Configuration\" tab to activate access to the \"Weather\" API in order to have access to the full information of your station.", + "energyApiNotConfigured": "Please go to the \"Configuration\" tab to activate access to the \"Energy\" API in order to have access to the full information of your devices.", + "descriptionGetApiKey": "You will then need to get your API key from your Tessie account.", + "apiKeyLabel": "Tessie API Key", + "apiKeyPlaceholder": "Enter your Tessie API key", + "websocketLabel": "Enable WebSocket", + "websocketDescription": "Enable WebSocket connection to receive real-time telemetry data", + "connectionInfoLabel": "Connection Information" + }, + "device": { + "title": "Tessie Devices in Gladys", + "descriptionInformation": "State retrieval is currently done every 2 minutes (Tessie API limitation).", + "noDeviceFound": "No Tessie devices have been added yet.", + "vinLabel": "Vehicle VIN", + "nameLabel": "Device name", + "namePlaceholder": "Enter your device name", + "modelLabel": "Model", + "roomLabel": "Room", + "connectedPlugLabel": "Connected to bridge", + "roomTessieApiLabel": "Room in Tessie API", + "featuresLabel": "Features", + "saveButton": "Save", + "saveNotAllowButton": "Save not possible", + "deleteButton": "Delete", + "noValueReceived": "No value received." + }, + "discover": { + "title": "Your Tessie Devices Compatible with Gladys", + "description": "Your Tessie devices must be added to your Tessie account before being added to Gladys, which currently only supports Thermostats and the valves associated with their Plug (API Energy) as well as weather stations and their modules (API Weather). If you are missing devices, please check that you have selected the correct APIs on the configuration page. The first discovery is automatic. Press 'Refresh' if you wish 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 (Tessie API limitation).", + "noDeviceFound": "No Tessie devices were found. If you are certain that you have a Tessie device supported by Gladys, have you completed all the steps in the documentation to register your devices on the Tessie API?", + "alreadyCreatedButton": "Already Created", + "unmanagedModelButton": "Unsupported model", + "updateButton": "Update", + "scan": "Scan", + "refresh": "Refresh" + }, + "setup": { + "title": "Tessie Setup", + "description": "You can connect Gladys to your Tessie account to control the associated devices.", + "descriptionCreateAccount": "You need to create an account on Tessie Connect.", + "descriptionCreateProject": "You will then need to create an \"application\" in your Tessie 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 Tessie 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.", + "titleAdditionalInformationEnergyApi": "Additional Information on the Operation of the \"Energy\" API:", + "descriptionAdditionalInformationEnergyApi": "You must select the \"Energy\" API at the bottom of the page to access the corresponding devices. 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.", + "titleAdditionalInformationWeatherApi": "Additional Information on the Operation of the \"Weather\" API:", + "descriptionAdditionalInformationWeatherApi": "You must select the \"Weather\" API at the bottom of the page to access the corresponding devices. If you also have devices belonging to the \"Energy\" API (Plug, thermostat or valve) you will have access to an additional functionality on indoor hygrometer devices to obtain the average temperature of the corresponding room. So don't forget to select the \"Energy\" API to access this functionality.", + "clientIdLabel": "Client ID", + "clientIdPlaceholder": "Client ID from My Apps in Tessie Connect", + "clientSecretLabel": "Client Secret", + "clientSecretPlaceholder": "Client secret from My Apps in Tessie 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 Tessie API when you click on these buttons.", + "energyApiLabel": "Enable \"Energy\" API (retrieve Tessie Plug, Thermostat, and Valve devices)", + "weatherApiLabel": "Enable \"Weather\" API (retrieve Tessie Weather Station devices)", + "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." + } + }, "tpLink": { "title": "TP-Link", "description": "Control TP-Link Bulbs and Plugs in Gladys", @@ -2799,6 +2886,7 @@ "percent": "Percent (%)", "pascal": "Pascal (Pa)", "hPa": "Hectopascal (hPa)", + "kPa": "Kilopascal (kPa)", "bar": "Bar", "psi": "Pound-force per square inch (psi)", "milli-bar": "Millibar (mbar)", @@ -2888,6 +2976,7 @@ "percent": "%", "pascal": "Pa", "hPa": "hPa", + "kPa": "kPa", "bar": "bar", "milli-bar": "mbar", "psi": "psi", @@ -3423,6 +3512,7 @@ "shortCategoryName": "Electric Vehicle - Climate", "climate-on": "Climate switch", "indoor-temperature": "Indoor temperature", + "outside-temperature": "Outside temperature", "target-temperature": "Target temperature" }, "electrical-vehicle-command": { @@ -3444,6 +3534,7 @@ "shortCategoryName": "Electric Vehicle - Status", "door-opened": "Door open", "odometer": "Odometer", + "tire-pressure": "Tire pressure", "window-opened": "Window open" }, "heater": { @@ -3860,4 +3951,4 @@ "upgradeYearlyError": "An error occured while switching to the annual plan, please contact us by email.", "upgradeYearlySuccess": "Thanks for switching to the annual plan!" } -} +} \ No newline at end of file diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index f293ed8703..04fbbf8b86 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -52,7 +52,23 @@ "device": { "searchPlaceHolder": "Chercher un appareil", "noFeatures": "Aucune fonctionnalité", - "tooMuchStatesToDelete": "Cet appareil a {{count}} états dans la base de données et ne peut être supprimé immédiatement. Gladys a lancé une suppression lente en arrière-plan de tous les états de cet appareil afin d'éviter qu'elle ne soit bloquée pendant la suppression de ceux-ci. Cette suppression peut durer quelques minutes /heures ( selon la rapidité de votre disque). Vous pouvez suivre la progression de la suppression ici. Lorsque ce processus sera terminé, nous vous invitons à revenir sur cet onglet pour effectuer la suppression définitive de cet appareil." + "tooMuchStatesToDelete": "Cet appareil a {{count}} états dans la base de données et ne peut être supprimé immédiatement. Gladys a lancé une suppression lente en arrière-plan de tous les états de cet appareil afin d'éviter qu'elle ne soit bloquée pendant la suppression de ceux-ci. Cette suppression peut durer quelques minutes /heures ( selon la rapidité de votre disque). Vous pouvez suivre la progression de la suppression ici. Lorsque ce processus sera terminé, nous vous invitons à revenir sur cet onglet pour effectuer la suppression définitive de cet appareil.", + "vehicle": { + "tesla": { + "modely": { + "standard": "Tesla Model Y Standard", + "long-range": "Tesla Model Y Long Range", + "awd": "Tesla Model Y Traction intégrale (AWD)", + "performance": "Tesla Model Y Performance" + }, + "model3": { + "tesla-model3-standard": "Tesla Model 3 standard", + "tesla-model3-long": "Tesla Model 3 Long Range", + "tesla-model3-awd": "Tesla Model 3 Traction intégrale (AWD)", + "tesla-model3-performance": "Tesla Model 3 Performance" + } + } + } }, "login": { "title": "Gladys Assistant", @@ -863,6 +879,94 @@ "conflictError": "Le topic actuel est déjà utilisé." } }, + "tessie": { + "title": "Tessie", + "description": "Contrôler les équipements Tessie", + "deviceTab": "Appareils", + "discoverTab": "Découverte Tessie", + "setupTab": "Configuration", + "documentation": "Documentation Tessie", + "discoverDeviceDescription": "Scanner automatiquement les appareils Tessie", + "status": { + "notConfigured": "Le service Tessie n'est pas configuré", + "disconnect": "Gladys n'est pas connectée à Tessie", + "notConnected": "Gladys n'a pas réussi à se connecter au compte Tessie, veuillez vérifier vos informations d'identification sur la page de configuration.", + "setupPageLink": "Page de configuration Tessie.", + "connect": "Gladys est connectée à Tessie", + "connecting": "Configuration sauvegardée. Connexion à votre compte Tessie en cours...", + "processingToken": "Connexion à votre compte Tessie... 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 Tessie 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 Tessie et que le compte n'est pas bani ou désactivé My App." + }, + "weatherApiNotConfigured": "Veuillez vous rendre dans l'onglet \"Configuration\" pour activer l'accès à l'API \"Weather\" dans le but d'avoir accès à la totalité des informations de votre station.", + "energyApiNotConfigured": "Veuillez vous rendre dans l'onglet \"Configuration\" pour activer l'accès à l'API \"Energy\" dans le but d'avoir accès à la totalité des informations de vos appareils." + }, + "device": { + "title": "Appareils Tessie dans Gladys", + "descriptionInformation": "La récupération des états se fait actuellement toutes les 2 minutes (limitation de l'API Tessie).", + "noDeviceFound": "Aucun appareil Tessie n'a encore été ajouté.", + "vinLabel": "VIN du véhicule", + "nameLabel": "Nom de l'appareil", + "namePlaceholder": "Entrez le nom de votre appareil", + "modelLabel": "Modèle", + "roomLabel": "Pièce", + "connectedPlugLabel": "Connecté au pont", + "roomTessieApiLabel": "Pièce dans l'API Tessie", + "featuresLabel": "Fonctionnalités", + "saveButton": "Sauvegarder", + "saveNotAllowButton": "Sauvegarde impossible", + "deleteButton": "Supprimer", + "noValueReceived": "Aucune valeur reçue." + }, + "discover": { + "title": "Vos appareils Tessie compatibles avec Gladys", + "description": "Vos appareils Tessie doivent être ajoutés à votre compte Tessie avant d'être ajoutés à Gladys qui ne supporte actuellement que les Thermostats et les vannes associés à leur Plug (API Energy) ainsi que les stations météo et leurs modules (API Weather). S'il vous manque des appareils, veuillez vérifier que vous avez bien sélectionné les bonnes API dans la page de configuration. 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 Tessie).", + "noDeviceFound": "Aucun appareil Tessie n'a été trouvé. Si vous êtes certains de posséder un appareil Tessie supporté par Gladys, avez-vous bien réalisé toutes les étapes de la documentation pour enregistrer vos appareils sur l'API Tessie ?", + "alreadyCreatedButton": "Déjà créé", + "updateButton": "Mettre à jour", + "unmanagedModelButton": "Modèle non pris en charge", + "scan": "Scanner", + "refresh": "Rafraîchir" + }, + "setup": { + "title": "Configuration Tessie", + "description": "Vous pouvez connecter Gladys à votre compte Tessie pour commander les appareils associés.", + "descriptionCreateAccount": "Vous avez besoin de créer un compte sur Tessie Connect.", + "descriptionCreateProject": "Vous devrez ensuite créer une \"application\" dans votre compte développeur Tessie 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 Tessie 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 à configurer, ces scopes sont automatiquement intégrés et vous seront 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ée.", + "titleAdditionalInformationEnergyApi": "Informations complémentaires sur le fonctionnement de l'API \"Energy\" :", + "descriptionAdditionalInformationEnergyApi": "Vous devez sélectionner l'API \"Energy\" en bas de page pour accéder aux appareils correspondants. 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.", + "titleAdditionalInformationWeatherApi": "Informations complémentaires sur le fonctionnement de l'API \"Weather\" :", + "descriptionAdditionalInformationWeatherApi": "Vous devez sélectionner l'API \"Weather\" en bas de page pour accéder aux appareils correspondants. Si vous avez également des appareils appartenant à l'API \"Energy\" (Plug, thermostat ou vanne) vous aurez accès à une fonctionnalité supplémentaire sur les appareils hygromètres intérieurs pour obtenir la température moyenne de la pièce correspondante. N'oubliez donc pas de sélectionner l'API \"Energy\" pour accéder à cette fonctionnalité.", + "clientIdLabel": "Identifiant client", + "clientIdPlaceholder": "client ID de My Apps dans Tessie Connect", + "clientSecretLabel": "Secret client", + "clientSecretPlaceholder": "client secret de My Apps dans Tessie 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 Tessie lorsque vous cliquerez sur ces boutons.", + "energyApiLabel": "Activer l'API \"Energy\" (récupérer les appareils Plug, Thermostat et Vannes Tessie)", + "weatherApiLabel": "Activer l'API \"Weather\" (récupérer les appareils de la station météo Tessie)", + "saveLabel": "Sauvegarder et connecter", + "disconnectLabel": "Déconnecter", + "descriptionGetApiKey": "Vous devrez ensuite obtenir votre clé API depuis votre compte Tessie.", + "apiKeyLabel": "Clé API Tessie", + "apiKeyPlaceholder": "Entrez votre clé API Tessie", + "websocketLabel": "Activer le WebSocket", + "websocketDescription": "Activer la connexion WebSocket pour recevoir les données de télémétrie en temps réel" + }, + "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." + } + }, "tpLink": { "title": "TP-Link", "description": "Contrôler les lumières et prises TP-Link.", @@ -2799,6 +2903,7 @@ "percent": "Pourcent (%)", "pascal": "Pascal (Pa)", "hPa": "Hectopascal (hPa)", + "kPa": "Kilopascal (kPa)", "bar": "Bar", "psi": "Livre-force par pouce carré (psi)", "milli-bar": "Millibar (mbar)", @@ -2888,6 +2993,7 @@ "percent": "%", "pascal": "Pa", "hPa": "hPa", + "kPa": "kPa", "bar": "bar", "psi": "psi", "milli-bar": "mbar", @@ -3423,6 +3529,7 @@ "shortCategoryName": "Véhicule électrique - Climatisation", "climate-on": "Commutateur climatisation", "indoor-temperature": "Température intérieure", + "outside-temperature": "Température extérieure", "target-temperature": "Température de consigne" }, "electrical-vehicle-command": { @@ -3444,6 +3551,7 @@ "shortCategoryName": "Véhicule électrique - État", "door-opened": "Porte ouverte", "odometer": "Odomètre", + "tire-pressure": "Pression des pneus", "window-opened": "Fenêtre ouverte" }, "heater": { @@ -3860,4 +3968,4 @@ "upgradeYearlyError": "Une erreur est survenue lors de la mise à jour vers le plan annuel, merci de nous contacter par email ou sur le forum.", "upgradeYearlySuccess": "Merci d'être passé au plan annuel !" } -} +} \ No newline at end of file diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json index 0804716a6b..9541817cae 100644 --- a/front/src/config/integrations/devices.json +++ b/front/src/config/integrations/devices.json @@ -99,5 +99,10 @@ "key": "matter", "link": "matter", "img": "/assets/integrations/cover/matter.jpg" + }, + { + "key": "tessie", + "link": "tessie", + "img": "/assets/integrations/cover/tessie.jpg" } ] diff --git a/front/src/routes/integration/all/tessie/TessieDeviceBox.jsx b/front/src/routes/integration/all/tessie/TessieDeviceBox.jsx new file mode 100644 index 0000000000..7db17fc8d0 --- /dev/null +++ b/front/src/routes/integration/all/tessie/TessieDeviceBox.jsx @@ -0,0 +1,1709 @@ +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, DEVICE_FEATURE_TYPES } from '../../../../../../server/utils/constants'; +import { + GITHUB_BASE_URL, + PARAMS, + SUPPORTED_CATEGORY_TYPE +} from '../../../../../../server/services/tessie/lib/utils/tessie.constants'; +import styles from './style.css'; +import withIntlAsProp from '../../../../utils/withIntlAsProp'; + +const createGithubUrl = device => { + const title = encodeURIComponent(`Tessie: Add device ${device.model}`); + const body = encodeURIComponent(`\`\`\`\n${JSON.stringify(device, null, 2)}\n\`\`\``); + return `${GITHUB_BASE_URL}?title=${title}&body=${body}`; +}; + +class TessieDeviceBox 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 { + this.state.device = { + "id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "service_id": "3b6e05eb-708c-422d-a6a3-a94c12518839", + "room_id": null, + "name": "Tahiti", + "selector": "tessie-xp7ygces3sb598663", + "model": "tesla-modely-pearlwhite", + "external_id": "tessie:XP7YGCES3SB598663", + "should_poll": false, + "poll_frequency": null, + "created_at": "2025-06-19T12:48:03.152Z", + "updated_at": "2025-06-19T12:48:03.152Z", + "features": [ + { + "id": "ab0182c9-8b70-4639-8adf-933bfb999884", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery Level", + "selector": "tessie-xp7ygces3sb598663-battery-level", + "external_id": "tessie:XP7YGCES3SB598663:battery_level", + "category": "electrical-vehicle-battery", + "type": "battery-level", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "percent", + "min": 0, + "max": 100, + "last_value": 98, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.898Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.165Z", + "updated_at": "2025-06-20T09:35:27.898Z", + "last_value_is_too_old": false + }, + { + "id": "a3e688fb-de81-4f88-ac07-75ab3400e80b", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery energy remaining", + "selector": "tessie-xp7ygces3sb598663-battery-energy-remaining", + "external_id": "tessie:XP7YGCES3SB598663:battery_energy_remaining", + "category": "electrical-vehicle-battery", + "type": "battery-energy-remaining", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 74, + "last_value": 76.259995, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.899Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.166Z", + "updated_at": "2025-06-20T09:35:27.899Z", + "last_value_is_too_old": false + }, + { + "id": "22178abf-72b4-4493-9656-444cdc69a9ca", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery range estimate", + "selector": "tessie-xp7ygces3sb598663-battery-range-estimate", + "external_id": "tessie:XP7YGCES3SB598663:battery_range_estimate", + "category": "electrical-vehicle-battery", + "type": "battery-range-estimate", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile", + "min": 0, + "max": 523, + "last_value": 318.41, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.899Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.166Z", + "updated_at": "2025-06-20T09:35:27.899Z", + "last_value_is_too_old": false + }, + { + "id": "fc5a9e04-2915-4f7e-a170-2e658b800e31", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery power", + "selector": "tessie-xp7ygces3sb598663-battery-power", + "external_id": "tessie:XP7YGCES3SB598663:battery_power", + "category": "electrical-vehicle-battery", + "type": "battery-power", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "watt", + "min": 0, + "max": 10000, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.899Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.167Z", + "updated_at": "2025-06-20T09:35:27.899Z", + "last_value_is_too_old": false + }, + { + "id": "63b4f845-80a0-49b2-9aa7-6c8065396dbb", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery temperature min", + "selector": "tessie-xp7ygces3sb598663-battery-temperature-min", + "external_id": "tessie:XP7YGCES3SB598663:battery_temperature_min", + "category": "electrical-vehicle-battery", + "type": "battery-temperature", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "celsius", + "min": -50, + "max": 100, + "last_value": 26, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.900Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.167Z", + "updated_at": "2025-06-20T09:35:27.900Z", + "last_value_is_too_old": false + }, + { + "id": "dddf6ff5-9079-4968-927b-4c9b393966b9", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery temperature max", + "selector": "tessie-xp7ygces3sb598663-battery-temperature-max", + "external_id": "tessie:XP7YGCES3SB598663:battery_temperature_max", + "category": "electrical-vehicle-battery", + "type": "battery-temperature", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "celsius", + "min": -50, + "max": 100, + "last_value": 26.5, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.902Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.167Z", + "updated_at": "2025-06-20T09:35:27.902Z", + "last_value_is_too_old": false + }, + { + "id": "ba3a5f1a-5e35-4d32-a737-84cf81573ebc", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Battery voltage", + "selector": "tessie-xp7ygces3sb598663-battery-voltage", + "external_id": "tessie:XP7YGCES3SB598663:battery_voltage", + "category": "electrical-vehicle-battery", + "type": "battery-voltage", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "volt", + "min": 0, + "max": 1000, + "last_value": 397.16, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.902Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.168Z", + "updated_at": "2025-06-20T09:35:27.902Z", + "last_value_is_too_old": false + }, + { + "id": "3532ea81-8936-4de1-a8ce-4101b10df834", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Charge current", + "selector": "tessie-xp7ygces3sb598663-charge-current", + "external_id": "tessie:XP7YGCES3SB598663:charge_current", + "category": "electrical-vehicle-charge", + "type": "charge-current", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "ampere", + "min": 0, + "max": 100, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.264Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.168Z", + "updated_at": "2025-06-20T07:51:38.265Z", + "last_value_is_too_old": false + }, + { + "id": "185a909b-6ee5-4c64-acd0-2b233568737d", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Charge energy added total", + "selector": "tessie-xp7ygces3sb598663-charge-energy-added-total", + "external_id": "tessie:XP7YGCES3SB598663:charge_energy_added_total", + "category": "electrical-vehicle-charge", + "type": "charge-energy-added-total", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 1655.7499999999998, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.266Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.169Z", + "updated_at": "2025-06-20T07:51:38.266Z", + "last_value_is_too_old": false + }, + { + "id": "c949ff79-961d-489a-8ee3-f001199d29a4", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Charge energy consumption total", + "selector": "tessie-xp7ygces3sb598663-charge-energy-consumption-total", + "external_id": "tessie:XP7YGCES3SB598663:charge_energy_consumption_total", + "category": "electrical-vehicle-charge", + "type": "charge-energy-consumption-total", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 1744.930000000001, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.267Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.169Z", + "updated_at": "2025-06-20T07:51:38.267Z", + "last_value_is_too_old": false + }, + { + "id": "13091433-3c2d-4c1f-9e07-0a4f044074d4", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Charge on", + "selector": "tessie-xp7ygces3sb598663-charge-on", + "external_id": "tessie:XP7YGCES3SB598663:charge_on", + "category": "electrical-vehicle-charge", + "type": "charge-on", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.268Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.170Z", + "updated_at": "2025-06-20T07:51:38.268Z", + "last_value_is_too_old": false + }, + { + "id": "a81a77c3-502f-488a-a70e-447359712f04", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Charge power", + "selector": "tessie-xp7ygces3sb598663-charge-power", + "external_id": "tessie:XP7YGCES3SB598663:charge_power", + "category": "electrical-vehicle-charge", + "type": "charge-power", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "watt", + "min": 0, + "max": 1000000, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.269Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.170Z", + "updated_at": "2025-06-20T07:51:38.269Z", + "last_value_is_too_old": false + }, + { + "id": "329f5156-edba-440e-843f-f727d2dbb6f9", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Charge voltage", + "selector": "tessie-xp7ygces3sb598663-charge-voltage", + "external_id": "tessie:XP7YGCES3SB598663:charge_voltage", + "category": "electrical-vehicle-charge", + "type": "charge-voltage", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "volt", + "min": 0, + "max": 1000, + "last_value": 1, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.270Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.171Z", + "updated_at": "2025-06-20T07:51:38.271Z", + "last_value_is_too_old": false + }, + { + "id": "a913585a-bdd1-4ee5-978c-99e61902b4d7", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last charge energy added", + "selector": "tessie-xp7ygces3sb598663-last-charge-energy-added", + "external_id": "tessie:XP7YGCES3SB598663:last_charge_energy_added", + "category": "electrical-vehicle-charge", + "type": "last-charge-energy-added", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 41.88, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.271Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.171Z", + "updated_at": "2025-06-20T07:51:38.272Z", + "last_value_is_too_old": false + }, + { + "id": "6c251c46-0ab8-463e-b937-c69ae8d603e6", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last charge energy consumption", + "selector": "tessie-xp7ygces3sb598663-last-charge-energy-consumption", + "external_id": "tessie:XP7YGCES3SB598663:last_charge_energy_consumption", + "category": "electrical-vehicle-charge", + "type": "last-charge-energy-consumption", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 43.36, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.272Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.171Z", + "updated_at": "2025-06-20T07:51:38.272Z", + "last_value_is_too_old": false + }, + { + "id": "b2f5c2a6-3f93-4eb2-8f66-c934049cfdfd", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Plugged", + "selector": "tessie-xp7ygces3sb598663-plugged", + "external_id": "tessie:XP7YGCES3SB598663:plugged", + "category": "electrical-vehicle-charge", + "type": "plugged", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.273Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.172Z", + "updated_at": "2025-06-20T07:51:38.273Z", + "last_value_is_too_old": false + }, + { + "id": "9808c875-ba9e-481f-a7f5-7d3718238b4a", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Target charge limit", + "selector": "tessie-xp7ygces3sb598663-target-charge-limit", + "external_id": "tessie:XP7YGCES3SB598663:target_charge_limit", + "category": "electrical-vehicle-charge", + "type": "target-charge-limit", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": "percent", + "min": 50, + "max": 100, + "last_value": 100, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.274Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.172Z", + "updated_at": "2025-06-20T07:51:38.274Z", + "last_value_is_too_old": false + }, + { + "id": "494c43cd-df1d-4e95-bdd9-ef8e97b52a2e", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Target current", + "selector": "tessie-xp7ygces3sb598663-target-current", + "external_id": "tessie:XP7YGCES3SB598663:target_current", + "category": "electrical-vehicle-charge", + "type": "target-current", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": "ampere", + "min": 0, + "max": 16, + "last_value": 16, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.275Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.181Z", + "updated_at": "2025-06-20T07:51:38.276Z", + "last_value_is_too_old": false + }, + { + "id": "8ba6ba1e-47e1-4293-bbff-194a468e573c", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Climate on", + "selector": "tessie-xp7ygces3sb598663-climate-on", + "external_id": "tessie:XP7YGCES3SB598663:climate_on", + "category": "electrical-vehicle-climate", + "type": "climate-on", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.903Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.182Z", + "updated_at": "2025-06-20T09:35:27.903Z", + "last_value_is_too_old": false + }, + { + "id": "2785c5d9-8a25-4549-81d7-bb016b05c6fb", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Indoor temperature", + "selector": "tessie-xp7ygces3sb598663-indoor-temperature", + "external_id": "tessie:XP7YGCES3SB598663:indoor_temperature", + "category": "electrical-vehicle-climate", + "type": "indoor-temperature", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "celsius", + "min": -50, + "max": 50, + "last_value": 23.9, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.903Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.182Z", + "updated_at": "2025-06-20T09:35:27.903Z", + "last_value_is_too_old": false + }, + { + "id": "52528d06-4a80-4228-a656-6d9d01209727", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Outside temperature", + "selector": "tessie-xp7ygces3sb598663-outside-temperature", + "external_id": "tessie:XP7YGCES3SB598663:outside_temperature", + "category": "electrical-vehicle-climate", + "type": "outside-temperature", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "celsius", + "min": -50, + "max": 50, + "last_value": 20.5, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.904Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.183Z", + "updated_at": "2025-06-20T09:35:27.904Z", + "last_value_is_too_old": false + }, + { + "id": "92f38bfd-074d-4610-b03b-0376fc6c9f2f", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Target temperature", + "selector": "tessie-xp7ygces3sb598663-target-temperature", + "external_id": "tessie:XP7YGCES3SB598663:target_temperature", + "category": "electrical-vehicle-climate", + "type": "target-temperature", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": "celsius", + "min": 15, + "max": 28, + "last_value": 21, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.904Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.183Z", + "updated_at": "2025-06-20T09:35:27.904Z", + "last_value_is_too_old": false + }, + { + "id": "12085da6-c274-4356-8205-0b740a70c9db", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Alarm", + "selector": "tessie-xp7ygces3sb598663-alarm", + "external_id": "tessie:XP7YGCES3SB598663:alarm", + "category": "electrical-vehicle-command", + "type": "alarm", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.904Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.183Z", + "updated_at": "2025-06-20T09:35:27.904Z", + "last_value_is_too_old": false + }, + { + "id": "9677a791-486d-41d1-8757-20f86b9e63cb", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Lock", + "selector": "tessie-xp7ygces3sb598663-lock", + "external_id": "tessie:XP7YGCES3SB598663:lock", + "category": "electrical-vehicle-command", + "type": "lock", + "read_only": false, + "keep_history": true, + "has_feedback": true, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:27.905Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.184Z", + "updated_at": "2025-06-20T09:35:27.905Z", + "last_value_is_too_old": false + }, + { + "id": "dfe8fdf7-1dd3-4521-a8f7-c9fe556f5062", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Drive energy consumption total", + "selector": "tessie-xp7ygces3sb598663-drive-energy-consumption-total", + "external_id": "tessie:XP7YGCES3SB598663:drive_energy_consumption_total", + "category": "electrical-vehicle-drive", + "type": "drive-energy-consumption-total", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 1377.7799999999975, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.913Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.184Z", + "updated_at": "2025-06-20T07:51:38.914Z", + "last_value_is_too_old": false + }, + { + "id": "f13087bf-13a2-454a-9a9e-a9fae473a432", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Speed", + "selector": "tessie-xp7ygces3sb598663-speed", + "external_id": "tessie:XP7YGCES3SB598663:speed", + "category": "electrical-vehicle-drive", + "type": "speed", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile-per-hour", + "min": 0, + "max": 250, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.914Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.185Z", + "updated_at": "2025-06-20T07:51:38.914Z", + "last_value_is_too_old": false + }, + { + "id": "75e31ba0-78d3-462b-9934-c7890e0b61c6", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive energy consumption", + "selector": "tessie-xp7ygces3sb598663-last-drive-energy-consumption", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_energy_consumption", + "category": "electrical-vehicle-drive", + "type": "drive-energy-consumption-total", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour", + "min": 0, + "max": 1000, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.914Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.185Z", + "updated_at": "2025-06-20T07:51:38.915Z", + "last_value_is_too_old": false + }, + { + "id": "4e0f77c2-f1cb-40df-81c1-861f31363bd7", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive distance", + "selector": "tessie-xp7ygces3sb598663-last-drive-distance", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_distance", + "category": "electrical-vehicle-state", + "type": "odometer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile", + "min": 0, + "max": 1000, + "last_value": 0.01, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.915Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.185Z", + "updated_at": "2025-06-20T07:51:38.915Z", + "last_value_is_too_old": false + }, + { + "id": "7e59dd21-976f-4d35-9eb1-f40e0bab36c6", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive average speed", + "selector": "tessie-xp7ygces3sb598663-last-drive-average-speed", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_average_speed", + "category": "electrical-vehicle-drive", + "type": "speed", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile-per-hour", + "min": 0, + "max": 250, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.915Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.186Z", + "updated_at": "2025-06-20T07:51:38.915Z", + "last_value_is_too_old": false + }, + { + "id": "01175c44-bd0d-49d8-a57f-529ebcf23928", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive max speed", + "selector": "tessie-xp7ygces3sb598663-last-drive-max-speed", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_max_speed", + "category": "electrical-vehicle-drive", + "type": "speed", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile-per-hour", + "min": 0, + "max": 250, + "last_value": 1, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.916Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.186Z", + "updated_at": "2025-06-20T07:51:38.916Z", + "last_value_is_too_old": false + }, + { + "id": "da7bffed-7f7d-4ba7-bc6e-7b5d9a2769ac", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive average inside temperature", + "selector": "tessie-xp7ygces3sb598663-last-drive-average-inside-temperature", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_average_inside_temperature", + "category": "electrical-vehicle-climate", + "type": "indoor-temperature", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "celsius", + "min": -50, + "max": 100, + "last_value": 21.9, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.916Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.187Z", + "updated_at": "2025-06-20T07:51:38.916Z", + "last_value_is_too_old": false + }, + { + "id": "f8818e09-893c-4a43-b7c4-eb504780cee7", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive average outside temperature", + "selector": "tessie-xp7ygces3sb598663-last-drive-average-outside-temperature", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_average_outside_temperature", + "category": "electrical-vehicle-climate", + "type": "outside-temperature", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "celsius", + "min": -50, + "max": 100, + "last_value": 20.5, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.917Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.187Z", + "updated_at": "2025-06-20T07:51:38.917Z", + "last_value_is_too_old": false + }, + { + "id": "9d578945-eef9-4575-962d-5179bf3ce19b", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive starting battery", + "selector": "tessie-xp7ygces3sb598663-last-drive-starting-battery", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_starting_battery", + "category": "electrical-vehicle-battery", + "type": "battery-level", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "percent", + "min": 0, + "max": 100, + "last_value": 98, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.917Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.187Z", + "updated_at": "2025-06-20T07:51:38.917Z", + "last_value_is_too_old": false + }, + { + "id": "41bbd6b7-8525-40c2-a044-710d653ae8f9", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Last drive ending battery", + "selector": "tessie-xp7ygces3sb598663-last-drive-ending-battery", + "external_id": "tessie:XP7YGCES3SB598663:last_drive_ending_battery", + "category": "electrical-vehicle-battery", + "type": "battery-level", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "percent", + "min": 0, + "max": 100, + "last_value": 98, + "last_value_string": null, + "last_value_changed": "2025-06-20T07:51:38.917Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.188Z", + "updated_at": "2025-06-20T07:51:38.918Z", + "last_value_is_too_old": false + }, + { + "id": "d4379843-8cd4-40f6-a21f-0c505b13ab99", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Energy consumption kWh/100km", + "selector": "tessie-xp7ygces3sb598663-energy-consumption-100mile", + "external_id": "tessie:XP7YGCES3SB598663:energy_consumption_100mile", + "category": "electrical-vehicle-consumption", + "type": "energy-consumption", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour-per-100-mile", + "min": 0, + "max": 200, + "last_value": 23.89937106918239, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.206Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.188Z", + "updated_at": "2025-06-20T09:35:28.207Z", + "last_value_is_too_old": false + }, + { + "id": "bc859287-ffd7-4d12-b31e-999e09ae1645", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Energy consumption kWh/100km by driving", + "selector": "tessie-xp7ygces3sb598663-energy-consumption-100mile-by-driving", + "external_id": "tessie:XP7YGCES3SB598663:energy_consumption_100mile_by_driving", + "category": "electrical-vehicle-consumption", + "type": "energy-consumption", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "kilowatt-hour-per-100-mile", + "min": 0, + "max": 200, + "last_value": 21.792618629173988, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.207Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.189Z", + "updated_at": "2025-06-20T09:35:28.207Z", + "last_value_is_too_old": false + }, + { + "id": "deaf8d6c-bca9-4ab4-a879-20224ce66158", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Energy consumption Wh/km", + "selector": "tessie-xp7ygces3sb598663-energy-consumption-mile", + "external_id": "tessie:XP7YGCES3SB598663:energy_consumption_mile", + "category": "electrical-vehicle-consumption", + "type": "energy-consumption", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "watt-hour-per-mile", + "min": 0, + "max": 2000, + "last_value": 316.6666666666667, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.208Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.189Z", + "updated_at": "2025-06-20T09:35:28.208Z", + "last_value_is_too_old": false + }, + { + "id": "f81acd97-bcd8-419d-ba98-152786c65653", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Energy consumption Wh/km by driving", + "selector": "tessie-xp7ygces3sb598663-energy-consumption-mile-by-driving", + "external_id": "tessie:XP7YGCES3SB598663:energy_consumption_mile_by_driving", + "category": "electrical-vehicle-consumption", + "type": "energy-consumption", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "watt-hour-per-mile", + "min": 0, + "max": 2000, + "last_value": 258.33333333333339, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.208Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.189Z", + "updated_at": "2025-06-20T09:35:28.208Z", + "last_value_is_too_old": false + }, + { + "id": "98f47471-f829-40b7-8f45-c5358071c881", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Energy efficiency", + "selector": "tessie-xp7ygces3sb598663-energy-efficiency", + "external_id": "tessie:XP7YGCES3SB598663:energy_efficiency", + "category": "electrical-vehicle-consumption", + "type": "energy-efficiency", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile-per-kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 3.1578947368421055, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.209Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.190Z", + "updated_at": "2025-06-20T09:35:28.209Z", + "last_value_is_too_old": false + }, + { + "id": "ea2ae8b0-1527-4cf8-807d-22e515380790", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Energy efficiency by driving", + "selector": "tessie-xp7ygces3sb598663-energy-efficiency-by-driving", + "external_id": "tessie:XP7YGCES3SB598663:energy_efficiency_by_driving", + "category": "electrical-vehicle-consumption", + "type": "energy-efficiency", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile-per-kilowatt-hour", + "min": 0, + "max": 999999999, + "last_value": 3.8709677419354837, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.209Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.190Z", + "updated_at": "2025-06-20T09:35:28.209Z", + "last_value_is_too_old": false + }, + { + "id": "89659a79-a0fd-4473-8a01-c8ee3a412723", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Front driver door opened", + "selector": "tessie-xp7ygces3sb598663-door-df-opened", + "external_id": "tessie:XP7YGCES3SB598663:door_df_opened", + "category": "electrical-vehicle-state", + "type": "door-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.209Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.190Z", + "updated_at": "2025-06-20T09:35:28.209Z", + "last_value_is_too_old": false + }, + { + "id": "5c25f1fb-3d54-4f65-a238-a839b435a1bb", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Front passenger door opened", + "selector": "tessie-xp7ygces3sb598663-door-pf-opened", + "external_id": "tessie:XP7YGCES3SB598663:door_pf_opened", + "category": "electrical-vehicle-state", + "type": "door-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.210Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.190Z", + "updated_at": "2025-06-20T09:35:28.210Z", + "last_value_is_too_old": false + }, + { + "id": "e4d30d1a-f604-438c-8bba-243483efc87e", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear driver door opened", + "selector": "tessie-xp7ygces3sb598663-door-dr-opened", + "external_id": "tessie:XP7YGCES3SB598663:door_dr_opened", + "category": "electrical-vehicle-state", + "type": "door-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.210Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.191Z", + "updated_at": "2025-06-20T09:35:28.210Z", + "last_value_is_too_old": false + }, + { + "id": "15e90ee3-4961-485c-9d2f-7e502150d5e8", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear passenger door opened", + "selector": "tessie-xp7ygces3sb598663-door-pr-opened", + "external_id": "tessie:XP7YGCES3SB598663:door_pr_opened", + "category": "electrical-vehicle-state", + "type": "door-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.210Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.191Z", + "updated_at": "2025-06-20T09:35:28.210Z", + "last_value_is_too_old": false + }, + { + "id": "3ffbda0a-58bd-4e6b-82eb-70b70a5ad367", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Frunk opened", + "selector": "tessie-xp7ygces3sb598663-door-ft-opened", + "external_id": "tessie:XP7YGCES3SB598663:door_ft_opened", + "category": "electrical-vehicle-state", + "type": "door-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.211Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.191Z", + "updated_at": "2025-06-20T09:35:28.211Z", + "last_value_is_too_old": false + }, + { + "id": "d56cb043-515d-404e-b5ad-34e999663c0e", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear trunk opened", + "selector": "tessie-xp7ygces3sb598663-door-rt-opened", + "external_id": "tessie:XP7YGCES3SB598663:door_rt_opened", + "category": "electrical-vehicle-state", + "type": "door-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.211Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.192Z", + "updated_at": "2025-06-20T09:35:28.211Z", + "last_value_is_too_old": false + }, + { + "id": "67ff0241-5d35-4788-b396-72d3d4862675", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Odometer", + "selector": "tessie-xp7ygces3sb598663-odometer", + "external_id": "tessie:XP7YGCES3SB598663:odometer", + "category": "electrical-vehicle-state", + "type": "odometer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "mile", + "min": 0, + "max": 999999999, + "last_value": 4946.767365, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.211Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.192Z", + "updated_at": "2025-06-20T09:35:28.211Z", + "last_value_is_too_old": false + }, + { + "id": "e1b74db3-7e07-4bf2-bbd9-9fb46b934b97", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Front left tire pressure", + "selector": "tessie-xp7ygces3sb598663-tire-pressure-fl", + "external_id": "tessie:XP7YGCES3SB598663:tire_pressure_fl", + "category": "electrical-vehicle-state", + "type": "tire-pressure", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "bar", + "min": 0, + "max": 5, + "last_value": 2.825, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.212Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.192Z", + "updated_at": "2025-06-20T09:35:28.212Z", + "last_value_is_too_old": false + }, + { + "id": "7f34f5f6-abdf-4249-9101-80494f7c8e65", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Front right tire pressure", + "selector": "tessie-xp7ygces3sb598663-tire-pressure-fr", + "external_id": "tessie:XP7YGCES3SB598663:tire_pressure_fr", + "category": "electrical-vehicle-state", + "type": "tire-pressure", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "bar", + "min": 0, + "max": 5, + "last_value": 2.85, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.213Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.203Z", + "updated_at": "2025-06-20T09:35:28.213Z", + "last_value_is_too_old": false + }, + { + "id": "a31d47f5-7844-4159-a97f-7519a6e2d9f1", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear left tire pressure", + "selector": "tessie-xp7ygces3sb598663-tire-pressure-rl", + "external_id": "tessie:XP7YGCES3SB598663:tire_pressure_rl", + "category": "electrical-vehicle-state", + "type": "tire-pressure", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "bar", + "min": 0, + "max": 5, + "last_value": 2.85, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.214Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.203Z", + "updated_at": "2025-06-20T09:35:28.214Z", + "last_value_is_too_old": false + }, + { + "id": "ffabd20d-144f-4c10-943b-c2a72ee7825b", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear right tire pressure", + "selector": "tessie-xp7ygces3sb598663-tire-pressure-rr", + "external_id": "tessie:XP7YGCES3SB598663:tire_pressure_rr", + "category": "electrical-vehicle-state", + "type": "tire-pressure", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": "bar", + "min": 0, + "max": 5, + "last_value": 2.825, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.214Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.203Z", + "updated_at": "2025-06-20T09:35:28.214Z", + "last_value_is_too_old": false + }, + { + "id": "8bb4b1c7-4c1b-405a-9c4f-e619e1b6f3d9", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Front driver window opened", + "selector": "tessie-xp7ygces3sb598663-window-fd-opened", + "external_id": "tessie:XP7YGCES3SB598663:window_fd_opened", + "category": "electrical-vehicle-state", + "type": "window-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.214Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.204Z", + "updated_at": "2025-06-20T09:35:28.214Z", + "last_value_is_too_old": false + }, + { + "id": "191dc8a2-1a45-4e3f-a387-4e4d6d9a67b8", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Front passenger window opened", + "selector": "tessie-xp7ygces3sb598663-window-fp-opened", + "external_id": "tessie:XP7YGCES3SB598663:window_fp_opened", + "category": "electrical-vehicle-state", + "type": "window-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.215Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.204Z", + "updated_at": "2025-06-20T09:35:28.215Z", + "last_value_is_too_old": false + }, + { + "id": "5281cf4e-d285-4c70-a1a5-aa99445f3d55", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear driver window opened", + "selector": "tessie-xp7ygces3sb598663-window-rd-opened", + "external_id": "tessie:XP7YGCES3SB598663:window_rd_opened", + "category": "electrical-vehicle-state", + "type": "window-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.215Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.204Z", + "updated_at": "2025-06-20T09:35:28.215Z", + "last_value_is_too_old": false + }, + { + "id": "c73dd1d6-8bef-4bf1-955c-3e7c7da230f2", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "Rear passenger window opened", + "selector": "tessie-xp7ygces3sb598663-window-rp-opened", + "external_id": "tessie:XP7YGCES3SB598663:window_rp_opened", + "category": "electrical-vehicle-state", + "type": "window-opened", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "unit": null, + "min": 0, + "max": 1, + "last_value": 0, + "last_value_string": null, + "last_value_changed": "2025-06-20T09:35:28.216Z", + "last_hourly_aggregate": null, + "last_daily_aggregate": null, + "last_monthly_aggregate": null, + "created_at": "2025-06-19T12:48:03.204Z", + "updated_at": "2025-06-20T09:35:28.216Z", + "last_value_is_too_old": false + } + ], + "params": [ + { + "id": "f25d2c52-f333-42da-96af-a44b636f7b7f", + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "vehicle_vin", + "value": "XP7YGCES3SB598663", + "created_at": "2025-06-19T12:48:03.428Z", + "updated_at": "2025-06-19T12:48:03.428Z" + }, + { + "device_id": "b5c3ab86-bde2-410a-aa20-a61f0bb49835", + "name": "vehicle_version", + "value": "long-range", + }, + ], + "room": null, + "service": { + "id": "3b6e05eb-708c-422d-a6a3-a94c12518839", + "pod_id": null, + "name": "tessie", + "selector": "tessie", + "version": "0.1.0", + "has_message_feature": false, + "status": "RUNNING", + "created_at": "2025-05-07T08:21:43.393Z", + "updated_at": "2025-06-20T08:25:15.967Z" + } +}; + const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device); + this.setState({ + device: savedDevice, + isSaving: true + }); + } catch (e) { + let errorMessage = 'integration.tessie.error.defaultError'; + if (e.response.status === 409) { + errorMessage = 'integration.tessie.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.getTessieDevices(); + } 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.tessie.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.ELECTRICAL_VEHICLE_BATTERY && deviceFeature.type === DEVICE_FEATURE_TYPES.BATTERY_LEVEL + ); + 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 vinDevice = null; + const vinDeviceParam = device.params.find(param => param.name === PARAMS.VEHICLE_VIN); + if (vinDeviceParam) { + vinDevice = vinDeviceParam.value; + } + + let versionModel = null; + const versionModelParam = device.params.find(param => param.name === PARAMS.VEHICLE_VERSION); + if (versionModelParam) { + versionModel = versionModelParam.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)); + return hasRecentFeature; + }; + const online = isDeviceReachable(device); + + return { + batteryLevel, + mostRecentValueAt, + versionModel, + vinDevice, + online, + }; + }; + + render( + { + deviceIndex, + editable, + deleteButton, + saveButton, + updateButton, + alreadyCreatedButton, + showMostRecentValueAt, + housesWithRooms + }, + { device, user, loading, errorMessage, tooMuchStatesError, statesNumber } + ) { + const { + batteryLevel, + mostRecentValueAt, + versionModel, + vinDevice, + online, + } = this.getDeviceProperty(); + const model = device.model.split('-')[1]; + const modelImage = `/assets/integrations/devices/vehicle/tesla/${model}/${device.model}.jpg`; + return ( +
+
+
+ +
}> + +  {device.name} +
+
+ {showMostRecentValueAt && batteryLevel && ( +
+ +
+ )} +
+
+
+
+
+ {errorMessage && ( +
+ +
+ )} + {tooMuchStatesError && ( +
+ +
+ )} +
+ { + e.target.onerror = null; + e.target.src = '/assets/integrations/cover/tessie.jpg'; + }} + alt={`Image de ${device.name}`} + className={styles['device-image-container']} + /> +
+
+ + + } + disabled={!editable} + /> + +
+ +
+ + +
+ {vinDevice && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+ +
+ {this.state.isSaving && alreadyCreatedButton && ( + + )} + + {updateButton && ( + + )} + + + + {deleteButton && ( + + )} + + {showMostRecentValueAt && ( +

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

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

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
{props.children}
+
+
+
+
+
+); + +export default TessiePage; diff --git a/front/src/routes/integration/all/tessie/device-page/DeviceTab.jsx b/front/src/routes/integration/all/tessie/device-page/DeviceTab.jsx new file mode 100644 index 0000000000..d322abd11f --- /dev/null +++ b/front/src/routes/integration/all/tessie/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 TessieDeviceBox from '../TessieDeviceBox'; +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.getTessieDevices(); + this.getHouses(); + } + + getTessieDevices = async () => { + this.setState({ + getTessieStatus: 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 tessieDevices = await this.props.httpClient.get('/api/v1/service/tessie/device', options); + this.setState({ + tessieDevices, + getTessieStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + getTessieStatus: 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.getTessieDevices(); + } + changeOrderDir(e) { + this.setState({ + orderDir: e.target.value + }); + this.getTessieDevices(); + } + + render(props, { orderDir, search, getTessieStatus, tessieDevices, housesWithRooms }) { + return ( +
+
+

+ +

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

+ +

+
+
+ {tessieDevices && + tessieDevices.length > 0 && + tessieDevices.map((device, index) => ( + + ))} + {!tessieDevices || (tessieDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DeviceTab); diff --git a/front/src/routes/integration/all/tessie/device-page/EmptyState.jsx b/front/src/routes/integration/all/tessie/device-page/EmptyState.jsx new file mode 100644 index 0000000000..8fef35b112 --- /dev/null +++ b/front/src/routes/integration/all/tessie/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/tessie/device-page/StateConnection.jsx b/front/src/routes/integration/all/tessie/device-page/StateConnection.jsx new file mode 100644 index 0000000000..11f65af305 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/StateConnection.jsx @@ -0,0 +1,45 @@ +import { Text } from 'preact-i18n'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +const StateConnection = props => ( +
+ {!props.accessDenied && + ((props.connectTessieStatus === STATUS.DISCOVERING_DEVICES && ( +

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/tessie/device-page/index.js b/front/src/routes/integration/all/tessie/device-page/index.js new file mode 100644 index 0000000000..0555e83129 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/index.js @@ -0,0 +1,139 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DeviceTab from './DeviceTab'; +import TessiePage from '../TessiePage'; +import { RequestStatus } from '../../../../../utils/consts'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; +import withIntlAsProp from '../../../../../utils/withIntlAsProp'; + +class DevicePage extends Component { + loadStatus = async () => { + try { + const tessieStatus = await this.props.httpClient.get('/api/v1/service/tessie/status'); + this.setState({ + connectTessieStatus: tessieStatus.status, + connected: tessieStatus.connected, + configured: tessieStatus.configured + }); + } catch (e) { + this.setState({ + tessieConnectionError: 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({ + connectTessieStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, this.updateStatus); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, + this.updateStatusError + ); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default withIntlAsProp(connect('user,session,httpClient', {})(DevicePage)); diff --git a/front/src/routes/integration/all/tessie/device-page/style.css b/front/src/routes/integration/all/tessie/device-page/style.css new file mode 100644 index 0000000000..5296d19690 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 35px; +} + +.tessieListBody { + min-height: 200px +} diff --git a/front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx new file mode 100644 index 0000000000..0039f3fe80 --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx @@ -0,0 +1,136 @@ +import { Text, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import EmptyState from './EmptyState'; +import StateConnection from './StateConnection'; +import style from './style.css'; +import TessieDeviceBox from '../TessieDeviceBox'; +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/tessie/discover'); + console.log('discoveredDevices', discoveredDevices); + 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/tessie/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/tessie/discover-page/EmptyState.jsx b/front/src/routes/integration/all/tessie/discover-page/EmptyState.jsx new file mode 100644 index 0000000000..c4094486bb --- /dev/null +++ b/front/src/routes/integration/all/tessie/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/tessie/discover-page/StateConnection.jsx b/front/src/routes/integration/all/tessie/discover-page/StateConnection.jsx new file mode 100644 index 0000000000..a57c04761f --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/StateConnection.jsx @@ -0,0 +1,49 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +const StateConnection = props => ( +
+ {!props.accessDenied && + ((props.connectTessieStatus === STATUS.DISCOVERING_DEVICES && ( +

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ + + + +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/tessie/discover-page/index.js b/front/src/routes/integration/all/tessie/discover-page/index.js new file mode 100644 index 0000000000..c16030c965 --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/index.js @@ -0,0 +1,133 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DiscoverTab from './DiscoverTab'; +import TessiePage from '../TessiePage'; +import { RequestStatus } from '../../../../../utils/consts'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +class TessieDiscoverPage extends Component { + loadStatus = async () => { + try { + const tessieStatus = await this.props.httpClient.get('/api/v1/service/tessie/status'); + this.setState({ + connectTessieStatus: tessieStatus.status, + connected: tessieStatus.connected, + configured: tessieStatus.configured + }); + } catch (e) { + this.setState({ + tessieConnectionError: 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({ + connectTessieStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, this.updateStatus); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, + this.updateStatusError + ); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient', {})(TessieDiscoverPage); diff --git a/front/src/routes/integration/all/tessie/discover-page/style.css b/front/src/routes/integration/all/tessie/discover-page/style.css new file mode 100644 index 0000000000..05974b8e4a --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 89px; +} + +.tessieListBody { + min-height: 200px; +} diff --git a/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx b/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx new file mode 100644 index 0000000000..026ed250a1 --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx @@ -0,0 +1,166 @@ +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/tessie/lib/utils/tessie.constants'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; + +class SetupTab extends Component { + showApiKeyTimer = null; + + async disconnectTessie(e) { + e.preventDefault(); + + await this.setState({ + tessieDisconnectStatus: RequestStatus.Getting + }); + try { + await this.props.httpClient.post('/api/v1/service/tessie/disconnect'); + this.props.updateStateInIndex({ connectTessieStatus: STATUS.DISCONNECTED }); + await this.setState({ + tessieDisconnectStatus: RequestStatus.Success + }); + } catch (e) { + await this.setState({ + tessieSaveSettingsStatus: RequestStatus.Error + }); + } + } + + updateApiKey = e => { + this.props.updateStateInIndex({ tessieApiKey: e.target.value }); + }; + + updateWebsocketEnabled = e => { + this.props.updateStateInIndex({ tessieWebsocketEnabled: e.target.checked }); + }; + + toggleApiKey = () => { + const { showApiKey } = this.state; + + if (this.showApiKeyTimer) { + clearTimeout(this.showApiKeyTimer); + this.showApiKeyTimer = null; + } + + this.setState({ showApiKey: !showApiKey }); + + if (!showApiKey) { + this.showApiKeyTimer = setTimeout(() => this.setState({ showApiKey: false }), 5000); + } + }; + + componentWillUnmount() { + if (this.showApiKeyTimer) { + clearTimeout(this.showApiKeyTimer); + this.showApiKeyTimer = null; + } + } + + render(props, state, { loading }) { + return ( +
+
+

+ +

+
+
+
+
+
+ +

+ + + +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/tessie/setup-page/index.js b/front/src/routes/integration/all/tessie/setup-page/index.js new file mode 100644 index 0000000000..75a82023c4 --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/index.js @@ -0,0 +1,318 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { route } from 'preact-router'; +import SetupTab from './SetupTab'; +import TessiePage from '../TessiePage'; +import withIntlAsProp from '../../../../../utils/withIntlAsProp'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; +import { RequestStatus } from '../../../../../utils/consts'; + +class TessieSetupPage extends Component { + getRedirectUri = async () => { + try { + const result = await this.props.httpClient.post('/api/v1/service/tessie/connect'); + const redirectUri = `${result.authUrl}&redirect_uri=${encodeURIComponent(this.state.redirectUriTessieSetup)}`; + 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, + redirectUriTessieSetup: `${window.location.origin}/dashboard/integration/device/tessie/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/tessie/status', { + statusType: STATUS.ERROR.CONNECTING, + message: this.props.error + }); + await this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + configured: true, + accessDenied: true, + messageAlert: this.props.error + }); + } else { + this.props.httpClient.post('/api/v1/service/tessie/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({ + connectTessieStatus: STATUS.PROCESSING_TOKEN, + connected: false, + configured: true, + errored: false + }); + const response = await this.props.httpClient.post('/api/v1/service/tessie/token', { + codeOAuth: this.props.code, + redirectUri: this.state.redirectUriTessieSetup, + state: this.props.state + }); + if (response) successfulNewToken = true; + await this.props.httpClient.post('/api/v1/service/tessie/variable/TESSIE_CONNECTED', { + value: successfulNewToken + }); + await this.setState({ + connectTessieStatus: STATUS.CONNECTED, + connected: true, + configured: true, + errored: false + }); + await this.props.httpClient.get('/api/v1/service/tessie/discover', { refresh: true }); + setTimeout(() => { + route('/dashboard/integration/device/tessie/setup', true); + }, 100); + } catch (e) { + console.error(e); + this.props.httpClient.post('/api/v1/service/tessie/status', { + statusType: STATUS.PROCESSING_TOKEN, + message: 'other_error' + }); + await this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + configured: true, + errored: true + }); + } + } + }; + + saveConfiguration = async e => { + e.preventDefault(); + + try { + await this.props.httpClient.post('/api/v1/service/tessie/configuration', { + apiKey: this.state.tessieApiKey, + websocketEnabled: this.state.tessieWebsocketEnabled + }); + await this.setState({ + tessieSaveSettingsStatus: RequestStatus.Success + }); + } catch (e) { + await this.setState({ + tessieSaveSettingsStatus: RequestStatus.Error, + errored: true + }); + } + try { + await this.setState({ + connectTessieStatus: STATUS.CONNECTING, + connected: false, + configured: true + }); + + try { + const result = await this.props.httpClient.post('/api/v1/service/tessie/connect'); + console.log('result', result); + // const redirectUri = `${result.authUrl}&redirect_uri=${encodeURIComponent(this.state.redirectUriTessieSetup)}`; + // await this.setState({ + // redirectUri + // }); + } catch (e) { + console.error(e); + await this.setState({ errored: true }); + } + // await this.getRedirectUri(); + // const redirectUri = this.state.redirectUri; + // const regex = /dashboard|integration|device|tessie|setup/; + if (result) { + // window.location.href = this.state.redirectUri; + await this.setState({ + connectTessieStatus: RequestStatus.Success, + connected: false, + configured: true + }); + } else { + console.error('Missing redirect URL'); + await this.setState({ + connectTessieStatus: STATUS.ERROR.CONNECTING, + connected: false + }); + } + } catch (e) { + console.error('Error when redirecting to tessie', e); + + await this.setState({ + connectTessieStatus: STATUS.ERROR.CONNECTING, + connected: false, + errored: true + }); + } + }; + + loadProps = async () => { + let configuration = {}; + try { + configuration = await this.props.httpClient.get('/api/v1/service/tessie/configuration'); + } catch (e) { + console.error(e); + await this.setState({ errored: true }); + } finally { + await this.setState({ + tessieApiKey: configuration.apiKey, + tessieWebsocketEnabled: configuration.websocketEnabled || false, + clientSecretChanges: false + }); + } + }; + + loadStatus = async () => { + try { + const tessieStatus = await this.props.httpClient.get('/api/v1/service/tessie/status'); + await this.setState({ + connectTessieStatus: tessieStatus.status, + connected: tessieStatus.connected, + configured: tessieStatus.configured + }); + } catch (e) { + await this.setState({ + tessieConnectionError: 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({ + connectTessieStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: 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.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatusError + ); + } + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, + this.updateStatusError + ); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatusError + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default withIntlAsProp(connect('user,session,httpClient', {})(TessieSetupPage)); diff --git a/front/src/routes/integration/all/tessie/setup-page/style.css b/front/src/routes/integration/all/tessie/setup-page/style.css new file mode 100644 index 0000000000..2824209290 --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/style.css @@ -0,0 +1,35 @@ +.highlightText { + text-decoration: underline; + font-weight: bold; +} + +.italicText { + font-style: italic; +} + +.btnTextLineSpacing { + line-height: 1.5; +} + +.customSwitchIndicator { + margin-right: 10px; +} + +/* 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/tessie/style.css b/front/src/routes/integration/all/tessie/style.css new file mode 100644 index 0000000000..8ae3456241 --- /dev/null +++ b/front/src/routes/integration/all/tessie/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 647de902e2..c3138f5582 100644 --- a/front/src/utils/consts.js +++ b/front/src/utils/consts.js @@ -446,6 +446,7 @@ export const DeviceFeatureCategoriesIcon = { [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_CLIMATE]: { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.CLIMATE_ON]: 'power', [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.INDOOR_TEMPERATURE]: 'thermometer', + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.OUTSIDE_TEMPERATURE]: 'thermometer', [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.TARGET_TEMPERATURE]: 'thermometer' }, [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_COMMAND]: { @@ -463,6 +464,7 @@ export const DeviceFeatureCategoriesIcon = { [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_STATE]: { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.DOOR_OPENED]: 'unlock', [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER]: 'trending-up', + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: 'disc', [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.WINDOW_OPENED]: 'square' } }; diff --git a/server/services/index.js b/server/services/index.js index c2d00e6cf8..264fba60fe 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -32,3 +32,4 @@ module.exports['zwavejs-ui'] = require('./zwavejs-ui'); module.exports['google-cast'] = require('./google-cast'); module.exports.airplay = require('./airplay'); module.exports['free-mobile'] = require('./free-mobile'); +module.exports.tessie = require('./tessie'); diff --git a/server/services/tessie/api/tessie.controller.js b/server/services/tessie/api/tessie.controller.js new file mode 100644 index 0000000000..647497569d --- /dev/null +++ b/server/services/tessie/api/tessie.controller.js @@ -0,0 +1,119 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); + +module.exports = function TessieController(tessieHandler) { + /** + * @api {get} /api/v1/service/tessie/configuration Get Tessie Configuration. + * @apiName getConfiguration + * @apiGroup Tessie + */ + async function getConfiguration(req, res) { + const configuration = await tessieHandler.getConfiguration(); + res.json(configuration); + } + + /** + * @api {get} /api/v1/service/tessie/status Get Tessie Status. + * @apiName getStatus + * @apiGroup Tessie + */ + async function getStatus(req, res) { + const result = await tessieHandler.getStatus(); + res.json(result); + } + + /** + * @api {post} /api/v1/service/tessie/configuration Save Tessie Configuration. + * @apiName saveConfiguration + * @apiGroup Tessie + */ + async function saveConfiguration(req, res) { + const result = await tessieHandler.saveConfiguration(req.body); + res.json({ + success: result, + }); + } + + /** + * @api {post} /api/v1/service/tessie/status save Tessie connection status + * @apiName saveStatus + * @apiGroup Tessie + */ + async function saveStatus(req, res) { + const result = await tessieHandler.saveStatus(req.body); + res.json({ + success: result, + }); + } + + /** + * @api {post} /api/v1/service/tessie/connect Connect tessie + * @apiName connect + * @apiGroup Tessie + */ + async function connect(req, res) { + await tessieHandler.getConfiguration(); + const result = await tessieHandler.connect(); + res.json(result); + } + + /** + * @api {post} /api/v1/service/tessie/disconnect Disconnect tessie + * @apiName disconnect + * @apiGroup Tessie + */ + async function disconnect(req, res) { + await tessieHandler.disconnect(); + res.json({ + success: true, + }); + } + + /** + * @api {get} /api/v1/service/tessie/discover Discover tessie devices from API. + * @apiName discover + * @apiGroup Tessie + */ + async function discover(req, res) { + let devices; + if (!tessieHandler.discoveredDevices || req.query.refresh === 'true') { + devices = await tessieHandler.discoverDevices(); + } else { + devices = tessieHandler.discoveredDevices.filter((device) => { + const existInGladys = tessieHandler.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + } + res.json(devices); + } + + return { + 'get /api/v1/service/tessie/configuration': { + authenticated: true, + controller: asyncMiddleware(getConfiguration), + }, + 'post /api/v1/service/tessie/configuration': { + authenticated: true, + controller: asyncMiddleware(saveConfiguration), + }, + 'get /api/v1/service/tessie/status': { + authenticated: true, + controller: asyncMiddleware(getStatus), + }, + 'post /api/v1/service/tessie/status': { + authenticated: true, + controller: asyncMiddleware(saveStatus), + }, + 'post /api/v1/service/tessie/connect': { + authenticated: true, + controller: asyncMiddleware(connect), + }, + 'post /api/v1/service/tessie/disconnect': { + authenticated: true, + controller: asyncMiddleware(disconnect), + }, + 'get /api/v1/service/tessie/discover': { + authenticated: true, + controller: asyncMiddleware(discover), + }, + }; +}; diff --git a/server/services/tessie/index.js b/server/services/tessie/index.js new file mode 100644 index 0000000000..d606ca0355 --- /dev/null +++ b/server/services/tessie/index.js @@ -0,0 +1,50 @@ +const logger = require('../../utils/logger'); +const tessieController = require('./api/tessie.controller'); + +const TessieHandler = require('./lib'); +const { STATUS } = require('./lib/utils/tessie.constants'); + +module.exports = function TessieService(gladys, serviceId) { + const tessieHandler = new TessieHandler(gladys, serviceId); + + /** + * @public + * @description This function starts service. + * @example + * gladys.services.tessie.start(); + */ + async function start() { + logger.info('Starting Tessie service', serviceId); + await tessieHandler.init(); + } + + /** + * @public + * @description This function stops the service. + * @example + * gladys.services.tessie.stop(); + */ + async function stop() { + logger.info('Stopping Tessie service'); + await tessieHandler.disconnect(); + } + + /** + * @public + * @description Test if Tessie is running. + * @returns {Promise} Returns true if Tessie is used. + * @example + * const used = await gladys.services.tessie.isUsed(); + */ + async function isUsed() { + return tessieHandler.status === STATUS.CONNECTED; + } + + return Object.freeze({ + start, + stop, + isUsed, + device: tessieHandler, + controllers: tessieController(tessieHandler), + }); +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesBattery.js b/server/services/tessie/lib/device/tessie.buildFeaturesBattery.js new file mode 100644 index 0000000000..a63f33ab9b --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesBattery.js @@ -0,0 +1,124 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @param {string} category - Gladys category. + * @param {number} batteryCapacity - Battery capacity. + * @param {number} batteryRangeMax - Battery range max. + * @param {object} chargeState - Charge state from Tessie. + * @param {object} driveState - Drive state from Tessie. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureBattery('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_BATTERY', batteryCapacity, batteryRangeMax, chargeState, driveState); + */ +function buildFeatureBattery(externalId, category, batteryCapacity, batteryRangeMax, chargeState, driveState) { + return [ + { + name: 'Battery Level', + external_id: `${externalId}:battery_level`, + selector: `${externalId}-battery-level`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_LEVEL, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 100, + last_value: chargeState?.usable_battery_level || chargeState?.battery_level, + }, + { + name: 'Battery energy remaining', + external_id: `${externalId}:battery_energy_remaining`, + selector: `${externalId}-battery-energy-remaining`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_ENERGY_REMAINING, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: batteryCapacity, + last_value: chargeState?.energy_remaining, + }, + { + name: 'Battery range estimate', + external_id: `${externalId}:battery_range_estimate`, + selector: `${externalId}-battery-range-estimate`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_RANGE_ESTIMATE, + unit: DEVICE_FEATURE_UNITS.MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: batteryRangeMax, + last_value: chargeState?.battery_range, + }, + { + name: 'Battery power', + external_id: `${externalId}:battery_power`, + selector: `${externalId}-battery-power`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_POWER, + unit: DEVICE_FEATURE_UNITS.KILOWATT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 10000, + last_value: driveState?.power, + }, + { + name: 'Battery temperature min', + external_id: `${externalId}:battery_temperature_min`, + selector: `${externalId}-battery-temperature-min`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + has_feedback: false, + keep_history: true, + min: -50, + max: 100, + last_value: chargeState?.module_temp_min, + }, + { + name: 'Battery temperature max', + external_id: `${externalId}:battery_temperature_max`, + selector: `${externalId}-battery-temperature-max`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + has_feedback: false, + keep_history: true, + min: -50, + max: 100, + last_value: chargeState?.module_temp_max, + }, + { + name: 'Battery voltage', + external_id: `${externalId}:battery_voltage`, + selector: `${externalId}-battery-voltage`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].BATTERY_VOLTAGE, + unit: DEVICE_FEATURE_UNITS.VOLT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1000, + last_value: chargeState?.pack_voltage, + }, + ]; +} + +module.exports = { + buildFeatureBattery, +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesCharge.js b/server/services/tessie/lib/device/tessie.buildFeaturesCharge.js new file mode 100644 index 0000000000..e58ebd995e --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesCharge.js @@ -0,0 +1,172 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @param {string} category - Gladys category. + * @param {object} chargeState - Charge state from Tessie. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureCharge('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_CHARGE', chargeState); + */ +function buildFeatureCharge(externalId, category, chargeState) { + return [ + { + name: 'Charge current', + external_id: `${externalId}:charge_current`, + selector: `${externalId}-charge-current`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CHARGE_CURRENT, + unit: DEVICE_FEATURE_UNITS.AMPERE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 100, + last_value: chargeState.charger_actual_current, + }, + { + name: 'Charge energy added total', + external_id: `${externalId}:charge_energy_added_total`, + selector: `${externalId}-charge-energy-added-total`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CHARGE_ENERGY_ADDED_TOTAL, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + }, + { + name: 'Charge energy consumption total', + external_id: `${externalId}:charge_energy_consumption_total`, + selector: `${externalId}-charge-energy-consumption-total`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CHARGE_ENERGY_CONSUMPTION_TOTAL, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + last_value: chargeState.lifetime_energy_used, + }, + { + name: 'Charge on', + external_id: `${externalId}:charge_on`, + selector: `${externalId}-charge-on`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CHARGE_ON, + read_only: false, + keep_history: true, + has_feedback: true, + min: 0, + max: 1, + last_value: chargeState.charging_state === 'Charging' ? 1 : 0, + }, + { + name: 'Charge power', + external_id: `${externalId}:charge_power`, + selector: `${externalId}-charge-power`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CHARGE_POWER, + unit: DEVICE_FEATURE_UNITS.KILOWATT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1000000, + last_value: chargeState.charger_power, + }, + { + name: 'Charge voltage', + external_id: `${externalId}:charge_voltage`, + selector: `${externalId}-charge-voltage`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CHARGE_VOLTAGE, + unit: DEVICE_FEATURE_UNITS.VOLT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1000, + last_value: chargeState.charger_voltage, + }, + { + name: 'Last charge energy added', + external_id: `${externalId}:last_charge_energy_added`, + selector: `${externalId}-last-charge-energy-added`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].LAST_CHARGE_ENERGY_ADDED, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + }, + { + name: 'Last charge energy consumption', + external_id: `${externalId}:last_charge_energy_consumption`, + selector: `${externalId}-last-charge-energy-consumption`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].LAST_CHARGE_ENERGY_CONSUMPTION, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + }, + { + name: 'Plugged', + external_id: `${externalId}:plugged`, + selector: `${externalId}-plugged`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].PLUGGED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: chargeState.charging_state !== 'Disconnected' ? 1 : 0, + }, + { + name: 'Target charge limit', + external_id: `${externalId}:target_charge_limit`, + selector: `${externalId}-target-charge-limit`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TARGET_CHARGE_LIMIT, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: false, + keep_history: true, + has_feedback: true, + min: chargeState.charge_limit_soc_min, + max: chargeState.charge_limit_soc_max, + last_value: chargeState.charge_limit_soc, + }, + { + name: 'Target current', + external_id: `${externalId}:target_current`, + selector: `${externalId}-target-current`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TARGET_CURRENT, + unit: DEVICE_FEATURE_UNITS.AMPERE, + read_only: false, + keep_history: true, + has_feedback: true, + min: 0, + max: chargeState.charge_current_request_max, + last_value: chargeState.charge_current_request, + }, + ]; +} + +module.exports = { + buildFeatureCharge, +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesClimate.js b/server/services/tessie/lib/device/tessie.buildFeaturesClimate.js new file mode 100644 index 0000000000..fb48a9fb1a --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesClimate.js @@ -0,0 +1,76 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureClimate('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_CLIMATE', climateState); + */ +function buildFeatureClimate(externalId, category, climateState) { + return [ + { + name: 'Climate on', + external_id: `${externalId}:climate_on`, + selector: `${externalId}-climate-on`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].CLIMATE_ON, + read_only: false, + keep_history: true, + has_feedback: true, + min: 0, + max: 1, + last_value: climateState.is_climate_on ? 1 : 0, + }, + { + name: 'Indoor temperature', + external_id: `${externalId}:indoor_temperature`, + selector: `${externalId}-indoor-temperature`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].INDOOR_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + has_feedback: false, + keep_history: true, + min: -50, + max: 50, + last_value: climateState.inside_temp, + }, + { + name: 'Outside temperature', + external_id: `${externalId}:outside_temperature`, + selector: `${externalId}-outside-temperature`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].OUTSIDE_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + has_feedback: false, + keep_history: true, + min: -50, + max: 50, + last_value: climateState.outside_temp, + }, + { + name: 'Target temperature', + external_id: `${externalId}:target_temperature`, + selector: `${externalId}-target-temperature`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TARGET_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: false, + keep_history: true, + has_feedback: true, + min: climateState.min_avail_temp, + max: climateState.max_avail_temp, + last_value: climateState.driver_temp_setting, + }, + ]; +} + +module.exports = { + buildFeatureClimate, +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesCommand.js b/server/services/tessie/lib/device/tessie.buildFeaturesCommand.js new file mode 100644 index 0000000000..6d4a75de94 --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesCommand.js @@ -0,0 +1,49 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @param {string} category - Gladys category. + * @param {object} vehicleState - Vehicle state from Tessie. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureCommand('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_COMMAND', vehicleState); + */ +function buildFeatureCommand(externalId, category, vehicleState) { + return [ + { + name: 'Alarm', + external_id: `${externalId}:alarm`, + selector: `${externalId}-alarm`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ALARM, + read_only: false, + keep_history: true, + has_feedback: true, + min: 0, + max: 1, + last_value: vehicleState.sentry_mode ? 1 : 0, + }, + { + name: 'Lock', + external_id: `${externalId}:lock`, + selector: `${externalId}-lock`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].LOCK, + read_only: false, + keep_history: true, + has_feedback: true, + min: 0, + max: 1, + last_value: vehicleState.locked ? 1 : 0, + }, + ]; +} + +module.exports = { + buildFeatureCommand, +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesConsumption.js b/server/services/tessie/lib/device/tessie.buildFeaturesConsumption.js new file mode 100644 index 0000000000..cf5bfddc5d --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesConsumption.js @@ -0,0 +1,100 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @param {string} category - Gladys category. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureConsumption('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_CONSUMPTION'); + */ +function buildFeatureConsumption(externalId, category) { + return [ + { + name: 'Energy consumption kWh/100km', + external_id: `${externalId}:energy_consumption_100mile`, + selector: `${externalId}-energy-consumption-100mile`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ENERGY_CONSUMPTION, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR_PER_100_MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 200, + }, + { + name: 'Energy consumption kWh/100km by driving', + external_id: `${externalId}:energy_consumption_100mile_by_driving`, + selector: `${externalId}-energy-consumption-100mile-by-driving`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ENERGY_CONSUMPTION, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR_PER_100_MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 200, + }, + { + name: 'Energy consumption Wh/km', + external_id: `${externalId}:energy_consumption_mile`, + selector: `${externalId}-energy-consumption-mile`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ENERGY_CONSUMPTION, + unit: DEVICE_FEATURE_UNITS.WATT_HOUR_PER_MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 2000, + }, + { + name: 'Energy consumption Wh/km by driving', + external_id: `${externalId}:energy_consumption_mile_by_driving`, + selector: `${externalId}-energy-consumption-mile-by-driving`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ENERGY_CONSUMPTION, + unit: DEVICE_FEATURE_UNITS.WATT_HOUR_PER_MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 2000, + }, + { + name: 'Energy efficiency', + external_id: `${externalId}:energy_efficiency`, + selector: `${externalId}-energy-efficiency`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ENERGY_EFFICIENCY, + unit: DEVICE_FEATURE_UNITS.MILE_PER_KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + }, + { + name: 'Energy efficiency by driving', + external_id: `${externalId}:energy_efficiency_by_driving`, + selector: `${externalId}-energy-efficiency-by-driving`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ENERGY_EFFICIENCY, + unit: DEVICE_FEATURE_UNITS.MILE_PER_KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + }, + ]; +} + +module.exports = { + buildFeatureConsumption, +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesDrive.js b/server/services/tessie/lib/device/tessie.buildFeaturesDrive.js new file mode 100644 index 0000000000..897d7739fe --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesDrive.js @@ -0,0 +1,154 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @param {string} category - Gladys category. + * @param {object} driveState - Drive state from Tessie. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureDrive('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_DRIVE', driveState); + */ +function buildFeatureDrive(externalId, category, driveState) { + return [ + { + name: 'Drive energy consumption total', + external_id: `${externalId}:drive_energy_consumption_total`, + selector: `${externalId}-drive-energy-consumption-total`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DRIVE_ENERGY_CONSUMPTION_TOTAL, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + }, + { + name: 'Speed', + external_id: `${externalId}:speed`, + selector: `${externalId}-speed`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].SPEED, + unit: DEVICE_FEATURE_UNITS.MILE_PER_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 250, + last_value: driveState.speed, + }, + { // TODO: Add last drive types + name: 'Last drive energy consumption', + external_id: `${externalId}:last_drive_energy_consumption`, + selector: `${externalId}-last-drive-energy-consumption`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DRIVE_ENERGY_CONSUMPTION_TOTAL, + unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1000, + }, + { + name: 'Last drive distance', + external_id: `${externalId}:last_drive_distance`, + selector: `${externalId}-last-drive-distance`, + category: DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_STATE, + type: DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER, + unit: DEVICE_FEATURE_UNITS.MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1000, + }, + { + name: 'Last drive average speed', + external_id: `${externalId}:last_drive_average_speed`, + selector: `${externalId}-last-drive-average-speed`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].SPEED, + unit: DEVICE_FEATURE_UNITS.MILE_PER_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 250, + }, + { + name: 'Last drive max speed', + external_id: `${externalId}:last_drive_max_speed`, + selector: `${externalId}-last-drive-max-speed`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].SPEED, + unit: DEVICE_FEATURE_UNITS.MILE_PER_HOUR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 250, + }, + { + name: 'Last drive average inside temperature', + external_id: `${externalId}:last_drive_average_inside_temperature`, + selector: `${externalId}-last-drive-average-inside-temperature`, + category: DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_CLIMATE, + type: DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.INDOOR_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + has_feedback: false, + keep_history: true, + min: -50, + max: 100, + }, + { + name: 'Last drive average outside temperature', + external_id: `${externalId}:last_drive_average_outside_temperature`, + selector: `${externalId}-last-drive-average-outside-temperature`, + category: DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_CLIMATE, + type: DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.OUTSIDE_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + has_feedback: false, + keep_history: true, + min: -50, + max: 100, + }, + { + name: 'Last drive starting battery', + external_id: `${externalId}:last_drive_starting_battery`, + selector: `${externalId}-last-drive-starting-battery`, + category: DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_BATTERY, + type: DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_LEVEL, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 100, + }, + { + name: 'Last drive ending battery', + external_id: `${externalId}:last_drive_ending_battery`, + selector: `${externalId}-last-drive-ending-battery`, + category: DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_BATTERY, + type: DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_LEVEL, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 100, + }, + ]; +} + +module.exports = { + buildFeatureDrive, +}; diff --git a/server/services/tessie/lib/device/tessie.buildFeaturesState.js b/server/services/tessie/lib/device/tessie.buildFeaturesState.js new file mode 100644 index 0000000000..d3a377f18a --- /dev/null +++ b/server/services/tessie/lib/device/tessie.buildFeaturesState.js @@ -0,0 +1,223 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} externalId - Gladys external ID. + * @param {string} category - Gladys category. + * @param {object} vehicleState - State from Tessie. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureState('tessie:vehicle_vin', 'ELECTRICAL_VEHICLE_STATE', vehicleState); + */ +function buildFeatureState(externalId, category, vehicleState) { + return [ + { + name: 'Front driver door opened', + external_id: `${externalId}:door_df_opened`, + selector: `${externalId}-door-df-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DOOR_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.df ? 1 : 0, + }, + { + name: 'Front passenger door opened', + external_id: `${externalId}:door_pf_opened`, + selector: `${externalId}-door-pf-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DOOR_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.pf ? 1 : 0, + }, + { + name: 'Rear driver door opened', + external_id: `${externalId}:door_dr_opened`, + selector: `${externalId}-door-dr-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DOOR_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.dr ? 1 : 0, + }, + { + name: 'Rear passenger door opened', + external_id: `${externalId}:door_pr_opened`, + selector: `${externalId}-door-pr-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DOOR_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.pr ? 1 : 0, + }, + { + name: 'Frunk opened', + external_id: `${externalId}:door_ft_opened`, + selector: `${externalId}-door-ft-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DOOR_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.ft ? 1 : 0, + }, + { + name: 'Rear trunk opened', + external_id: `${externalId}:door_rt_opened`, + selector: `${externalId}-door-rt-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].DOOR_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.rt ? 1 : 0, + }, + { + name: 'Odometer', + external_id: `${externalId}:odometer`, + selector: `${externalId}-odometer`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].ODOMETER, + unit: DEVICE_FEATURE_UNITS.MILE, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 999999999, + last_value: vehicleState.odometer, + }, + { + name: 'Front left tire pressure', + external_id: `${externalId}:tire_pressure_fl`, + selector: `${externalId}-tire-pressure-fl`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TIRE_PRESSURE, + unit: DEVICE_FEATURE_UNITS.BAR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 5, + last_value: vehicleState.tpms_pressure_fl, + }, + { + name: 'Front right tire pressure', + external_id: `${externalId}:tire_pressure_fr`, + selector: `${externalId}-tire-pressure-fr`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TIRE_PRESSURE, + unit: DEVICE_FEATURE_UNITS.BAR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 5, + last_value: vehicleState.tpms_pressure_fr, + }, + { + name: 'Rear left tire pressure', + external_id: `${externalId}:tire_pressure_rl`, + selector: `${externalId}-tire-pressure-rl`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TIRE_PRESSURE, + unit: DEVICE_FEATURE_UNITS.BAR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 5, + last_value: vehicleState.tpms_pressure_rl, + }, + { + name: 'Rear right tire pressure', + external_id: `${externalId}:tire_pressure_rr`, + selector: `${externalId}-tire-pressure-rr`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].TIRE_PRESSURE, + unit: DEVICE_FEATURE_UNITS.BAR, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 5, + last_value: vehicleState.tpms_pressure_rr, + }, + { + name: 'Front driver window opened', + external_id: `${externalId}:window_fd_opened`, + selector: `${externalId}-window-fd-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].WINDOW_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.fd_window ? 1 : 0, + }, + { + name: 'Front passenger window opened', + external_id: `${externalId}:window_fp_opened`, + selector: `${externalId}-window-fp-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].WINDOW_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.fp_window ? 1 : 0, + }, + { + name: 'Rear driver window opened', + external_id: `${externalId}:window_rd_opened`, + selector: `${externalId}-window-rd-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].WINDOW_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.rd_window ? 1 : 0, + }, + { + name: 'Rear passenger window opened', + external_id: `${externalId}:window_rp_opened`, + selector: `${externalId}-window-rp-opened`, + category: DEVICE_FEATURE_CATEGORIES[category], + type: DEVICE_FEATURE_TYPES[category].WINDOW_OPENED, + read_only: true, + has_feedback: false, + keep_history: true, + min: 0, + max: 1, + last_value: vehicleState.rp_window ? 1 : 0, + }, + ]; +} + +module.exports = { + buildFeatureState, +}; diff --git a/server/services/tessie/lib/device/tessie.convertDeviceNotSupported.js b/server/services/tessie/lib/device/tessie.convertDeviceNotSupported.js new file mode 100644 index 0000000000..d337cba738 --- /dev/null +++ b/server/services/tessie/lib/device/tessie.convertDeviceNotSupported.js @@ -0,0 +1,40 @@ +const logger = require('../../../../utils/logger'); +const { PARAMS } = require('../utils/tessie.constants'); + +/** + * @description Transform Tessie device not supported to Gladys device. + * @param {object} netatmoDevice - Tessie device. + * @returns {object} Gladys device. + * @example + * tessie.convertDeviceNotSupported({ ... }); + */ +function convertDeviceNotSupported(netatmoDevice) { + const { home, name, type: model, room = {} } = netatmoDevice; + const id = netatmoDevice.id || netatmoDevice._id; + const homeId = home || netatmoDevice.home_id; + const nameDevice = name || netatmoDevice.module_name || netatmoDevice.station_name; + const externalId = `tessie:${id}`; + logger.debug(`Tessie convert device not supported "${nameDevice}, ${model}"`); + /* params common to all devices features */ + const params = [{ 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: nameDevice, + external_id: externalId, + selector: externalId, + model, + service_id: this.serviceId, + should_poll: false, + features: [], + params: params.filter((param) => param), + not_handled: true, + }; + logger.info(`Tessie device not supported "${nameDevice}, ${model}" converted`); + return device; +} + +module.exports = { + convertDeviceNotSupported, +}; diff --git a/server/services/tessie/lib/device/tessie.convertVehicle.js b/server/services/tessie/lib/device/tessie.convertVehicle.js new file mode 100644 index 0000000000..630bf4d083 --- /dev/null +++ b/server/services/tessie/lib/device/tessie.convertVehicle.js @@ -0,0 +1,110 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { SUPPORTED_MODULE_TYPE, PARAMS } = require('../utils/tessie.constants'); +const { buildFeatureBattery } = require('./tessie.buildFeaturesBattery'); +const { buildFeatureCharge } = require('./tessie.buildFeaturesCharge'); +const { buildFeatureClimate } = require('./tessie.buildFeaturesClimate'); +const { buildFeatureCommand } = require('./tessie.buildFeaturesCommand'); +const { buildFeatureConsumption } = require('./tessie.buildFeaturesConsumption'); +const { buildFeatureDrive } = require('./tessie.buildFeaturesDrive'); +const { buildFeatureState } = require('./tessie.buildFeaturesState'); + +/** + * @description Convert a Tessie vehicle to Gladys device format. + * @param {object} vehicleData - The vehicle data from Tessie API. + * @returns {object} The converted device. + * @example + * convertVehicle(vehicleData); + */ +function convertVehicle(vehicleData) { + logger.debug(`Converting Tessie vehicle ${vehicleData.id} to Gladys device format`); + const { + vin, + name, + model: carModel, + type, + exteriorColor, + year, + version, + batteryCapacity, + batteryRangeMax, + vehicle, + } = vehicleData; + + const model = `tesla-${carModel}-${exteriorColor?.toLowerCase() || 'unknown'}`; + const device = { + name, + external_id: `tessie:${vin}`, + selector: `tessie-${vin}`, + model, + features: [], + params: [ + { + name: PARAMS.VEHICLE_VIN, + value: vehicle.vin, + }, + { + name: PARAMS.VEHICLE_VERSION, + value: version, + }, + ], + }; + + // Add features based on the vehicle state + if (vehicle.last_state) { + const { + charge_state: chargeState, + drive_state: driveState, + climate_state: climateState, + vehicle_state: vehicleState, + } = vehicle.last_state; + + // Battery features + const batteryFeatures = buildFeatureBattery( + `tessie:${vin}`, + 'ELECTRICAL_VEHICLE_BATTERY', + batteryCapacity, + batteryRangeMax, + chargeState, + driveState, + ); + + // Charging state + const chargeFeatures = buildFeatureCharge(`tessie:${vin}`, 'ELECTRICAL_VEHICLE_CHARGE', chargeState); + + // Climate features + const climateFeatures = buildFeatureClimate(`tessie:${vin}`, 'ELECTRICAL_VEHICLE_CLIMATE', climateState); + + // Command features + const commandFeatures = buildFeatureCommand(`tessie:${vin}`, 'ELECTRICAL_VEHICLE_COMMAND', vehicleState); + + // Drive features + const driveFeatures = buildFeatureDrive(`tessie:${vin}`, 'ELECTRICAL_VEHICLE_DRIVE', driveState); + + // Consumption features + const consumptionFeatures = buildFeatureConsumption(`tessie:${vin}`, 'ELECTRICAL_VEHICLE_CONSUMPTION'); + + // Vehicle state + const vehicleStateFeatures = buildFeatureState(`tessie:${vin}`, 'ELECTRICAL_VEHICLE_STATE', vehicle.last_state); + + device.features.push( + ...batteryFeatures, + ...chargeFeatures, + ...climateFeatures, + ...commandFeatures, + ...driveFeatures, + ...consumptionFeatures, + ...vehicleStateFeatures, + ); + } + + return device; +} + +module.exports = { + convertVehicle, +}; diff --git a/server/services/tessie/lib/device/tessie.deviceMapping.js b/server/services/tessie/lib/device/tessie.deviceMapping.js new file mode 100644 index 0000000000..e7012659f0 --- /dev/null +++ b/server/services/tessie/lib/device/tessie.deviceMapping.js @@ -0,0 +1,111 @@ +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 or 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 or battery_state: 'medium' */ + [DEVICE_FEATURE_TYPES.BATTERY.INTEGER]: (valueFromDevice) => { + const batteryLevels = { + max: 100, + full: 90, + high: 75, + medium: 50, + low: 25, + 'very low': 10, + }; + + const valueToGladys = + batteryLevels[valueFromDevice] !== undefined ? batteryLevels[valueFromDevice] : 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 or 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; + }, + }, + [DEVICE_FEATURE_CATEGORIES.CO2_SENSOR]: { + /* co2: 550 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR]: { + /* humidity: 26 */ + [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: (valueFromDevice) => { + return valueFromDevice; + }, + }, + [DEVICE_FEATURE_CATEGORIES.NOISE_SENSOR]: { + /* noise: 32 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.PRESSURE_SENSOR]: { + /* pressure: 1050 or absolute_pressure: 1018 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.SPEED_SENSOR]: { + /* wind_strength: 5 */ + [DEVICE_FEATURE_TYPES.SPEED_SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.ANGLE_SENSOR]: { + /* wind_angle: 120 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.PRECIPITATION_SENSOR]: { + /* rain: 1.5 or sum_rain_1: 5.1 or sum_rain_24: 10.1 */ + [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: (valueFromDevice) => { + return valueFromDevice; + }, + }, +}; + +module.exports = { readValues, writeValues }; diff --git a/server/services/tessie/lib/index.js b/server/services/tessie/lib/index.js new file mode 100644 index 0000000000..d9f1951acc --- /dev/null +++ b/server/services/tessie/lib/index.js @@ -0,0 +1,83 @@ +const { init } = require('./tessie.init'); +const { connect } = require('./tessie.connect'); +const { convertVehicle } = require('./device/tessie.convertVehicle'); +const { disconnect } = require('./tessie.disconnect'); +const { discoverDevices } = require('./tessie.discoverDevices'); +const { getConfiguration } = require('./tessie.getConfiguration'); +const { getStatus } = require('./tessie.getStatus'); +const { loadVehicles } = require('./tessie.loadVehicles'); +const { refreshTessieValues, startPolling } = require('./tessie.pollRefreshingValues'); +const { saveConfiguration } = require('./tessie.saveConfiguration'); +const { saveStatus } = require('./tessie.saveStatus'); +const { setValue } = require('./tessie.setValue'); +const { updateValues } = require('./tessie.updateValues'); +const { updateBattery } = require('./update/tessie.updateBattery'); +const { updateCharge } = require('./update/tessie.updateCharge'); +const { updateClimate } = require('./update/tessie.updateClimate'); +const { updateCommand } = require('./update/tessie.updateCommand'); +const { updateConsumption } = require('./update/tessie.updateConsumption'); +const { updateDrive } = require('./update/tessie.updateDrive'); +const { updateState } = require('./update/tessie.updateState'); +const { + connectWebSocket, + handleWebSocketMessage, + updateValuesFromWebSocket, + getWebSocketFeatureMapping, + parseWebSocketValue, + shouldUpdateFeature, + reconnectWebSocket, + disconnectAllWebSockets, + initWebSocketConnections, +} = require('./tessie.websocket'); + +const { STATUS } = require('./utils/tessie.constants'); + +const TessieHandler = function TessieHandler(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.configuration = { + apiKey: null, + websocketEnabled: false, + }; + this.stateVehicle = null; + this.configured = false; + this.connected = false; + this.status = STATUS.NOT_INITIALIZED; + this.pollRefreshValues = undefined; + this.currentPollingInterval = null; + this.vehicles = []; + this.websocketConnections = new Map(); +}; + +TessieHandler.prototype.init = init; +TessieHandler.prototype.connect = connect; +TessieHandler.prototype.convertVehicle = convertVehicle; +TessieHandler.prototype.disconnect = disconnect; +TessieHandler.prototype.discoverDevices = discoverDevices; +TessieHandler.prototype.getConfiguration = getConfiguration; +TessieHandler.prototype.getStatus = getStatus; +TessieHandler.prototype.loadVehicles = loadVehicles; +TessieHandler.prototype.refreshTessieValues = refreshTessieValues; +TessieHandler.prototype.startPolling = startPolling; +TessieHandler.prototype.saveConfiguration = saveConfiguration; +TessieHandler.prototype.saveStatus = saveStatus; +TessieHandler.prototype.setValue = setValue; +TessieHandler.prototype.updateValues = updateValues; +TessieHandler.prototype.updateBattery = updateBattery; +TessieHandler.prototype.updateCharge = updateCharge; +TessieHandler.prototype.updateClimate = updateClimate; +TessieHandler.prototype.updateCommand = updateCommand; +TessieHandler.prototype.updateConsumption = updateConsumption; +TessieHandler.prototype.updateDrive = updateDrive; +TessieHandler.prototype.updateState = updateState; +TessieHandler.prototype.connectWebSocket = connectWebSocket; +TessieHandler.prototype.handleWebSocketMessage = handleWebSocketMessage; +TessieHandler.prototype.updateValuesFromWebSocket = updateValuesFromWebSocket; +TessieHandler.prototype.getWebSocketFeatureMapping = getWebSocketFeatureMapping; +TessieHandler.prototype.parseWebSocketValue = parseWebSocketValue; +TessieHandler.prototype.shouldUpdateFeature = shouldUpdateFeature; +TessieHandler.prototype.reconnectWebSocket = reconnectWebSocket; +TessieHandler.prototype.disconnectAllWebSockets = disconnectAllWebSockets; +TessieHandler.prototype.initWebSocketConnections = initWebSocketConnections; + +module.exports = TessieHandler; diff --git a/server/services/tessie/lib/tessie.connect.js b/server/services/tessie/lib/tessie.connect.js new file mode 100644 index 0000000000..a1d6ada135 --- /dev/null +++ b/server/services/tessie/lib/tessie.connect.js @@ -0,0 +1,71 @@ +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/tessie.constants'); + +/** + * @description Connect to Tessie using API key. + * @returns {Promise} Tessie connection status. + * @example + * connect(); + */ +async function connect() { + const { apiKey } = this.configuration; + if (!apiKey) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } + await this.saveStatus({ statusType: STATUS.CONNECTING, message: null }); + logger.debug('Connecting to Tessie...'); + console.log('apiKey', apiKey); + try { + const response = await fetch(`${API.VEHICLES}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + accept: API.HEADER.ACCEPT, + }, + }); + + if (response.status === 200) { + const rawBody = await response.text(); + const parsedBody = JSON.parse(rawBody); + const vehicles = parsedBody.results.map((vehicle) => ({ + vin: vehicle.vin, + name: vehicle.last_state.vehicle_state?.vehicle_name, + type: vehicle.last_state.vehicle_config?.car_type, + specialType: vehicle.last_state.vehicle_config?.car_special_type, + isActive: vehicle.is_active, + vehicle, + })); + this.vehicles = vehicles; + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + this.configured = true; + + // Démarrer le polling si des véhicules sont disponibles + if (vehicles.length > 0) { + await this.refreshTessieValues(); + await this.startPolling(); + + // Initialiser les connexions WebSocket si activé + if (this.configuration.websocketEnabled) { + await this.initWebSocketConnections(); + } + } + + return { vehicles }; + } else { + throw new Error('Failed to connect to Tessie'); + } + } catch (error) { + await this.saveStatus({ + statusType: STATUS.ERROR.CONNECTING, + message: error.message, + }); + throw error; + } +} + +module.exports = { + connect, +}; diff --git a/server/services/tessie/lib/tessie.disconnect.js b/server/services/tessie/lib/tessie.disconnect.js new file mode 100644 index 0000000000..92c9aa867d --- /dev/null +++ b/server/services/tessie/lib/tessie.disconnect.js @@ -0,0 +1,47 @@ +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/tessie.constants'); + +/** + * @description Disconnect from Tessie. + * @returns {Promise} The result of the disconnection. + * @example + * await disconnect(); + */ +async function disconnect() { + logger.debug('Disconnecting from Tessie...'); + await this.saveStatus({ statusType: STATUS.DISCONNECTING, message: null }); + + try { + // Arrêter le polling s'il est actif + if (this.pollRefreshValues) { + clearInterval(this.pollRefreshValues); + this.pollRefreshValues = undefined; + this.currentPollingInterval = null; + logger.debug('Polling stopped'); + } + + // Déconnecter tous les WebSockets + await this.disconnectAllWebSockets(); + + // Supprimer l'API key + await this.gladys.variable.destroy(this.serviceId, 'TESSIE_API_KEY'); + + // Réinitialiser la configuration + this.configuration = { + apiKey: null, + websocketEnabled: false, + }; + + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + logger.debug('Disconnected from Tessie successfully'); + return true; + } catch (e) { + logger.error('Error disconnecting from Tessie:', e); + await this.saveStatus({ statusType: STATUS.ERROR.DISCONNECTING, message: e.message }); + throw e; + } +} + +module.exports = { + disconnect, +}; diff --git a/server/services/tessie/lib/tessie.discoverDevices.js b/server/services/tessie/lib/tessie.discoverDevices.js new file mode 100644 index 0000000000..bfd2ab1c38 --- /dev/null +++ b/server/services/tessie/lib/tessie.discoverDevices.js @@ -0,0 +1,81 @@ +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const { + STATUS, + SUPPORTED_CATEGORY_TYPE, + EFFICIENCY_PACKAGE_YEAR, + TRIM_BADGING_TO_VERSION, + BATTERY_CAPACITY, +} = require('./utils/tessie.constants'); + +/** + * @description Discover Tessie vehicles. + * @returns {Promise} List of discovered vehicles. + * @example + * await discoverDevices(); + */ +async function discoverDevices() { + logger.debug('Looking for Tessie vehicles...'); + if (this.status !== STATUS.CONNECTED) { + await this.saveStatus({ statusType: this.status, message: null }); + throw new ServiceNotConfiguredError('Unable to discover Tessie vehicles until service is not well configured'); + } + this.discoveredDevices = []; + await this.saveStatus({ statusType: STATUS.DISCOVERING_DEVICES, message: null }); + + let vehicles = []; + try { + vehicles = await this.loadVehicles(); + logger.info(`${vehicles.length} Tessie vehicles found`); + } catch (e) { + logger.error('Unable to load Tessie vehicles', e); + } + if (vehicles.length > 0) { + this.discoveredDevices = vehicles.map((vehicle) => { + const carType = vehicle.last_state.vehicle_config?.car_type?.toUpperCase(); + const efficiencyPackage = vehicle.last_state.vehicle_config?.efficiency_package; + const year = efficiencyPackage?.split(carType)[1]?.match(/\d{4}/)?.[0] || '2024'; + const trimBadging = vehicle.last_state.vehicle_config?.trim_badging; + const version = trimBadging?.startsWith('P') + ? 'performance' + : trimBadging?.startsWith('7') || trimBadging?.startsWith('8') || trimBadging?.startsWith('9') + ? 'long-range' + : 'standard'; + + const vehicleData = { + vin: vehicle.vin, + name: vehicle.last_state.vehicle_state?.vehicle_name, + model: vehicle.last_state.vehicle_config?.car_type, + type: vehicle.last_state.vehicle_config?.car_special_type, + exteriorColor: vehicle.last_state.vehicle_config?.exterior_color, + year, + version, + batteryCapacity: BATTERY_CAPACITY[carType]?.[version?.toUpperCase()]?.BATTERY_CAPACITY || 60, + batteryRangeMax: BATTERY_CAPACITY[carType]?.[version?.toUpperCase()]?.BATTERY_RANGE || 250, + isActive: vehicle.is_active, + vehicle, + }; + const discoveredDevice = this.convertVehicle(vehicleData); + + return { + ...discoveredDevice, + service_id: this.serviceId, + vehicleData, + }; + }); + const discoveredDevices = this.discoveredDevices.filter((device) => { + const existInGladys = this.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug(`${discoveredDevices.length} new Tessie vehicles found`); + return discoveredDevices; + } + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug('No vehicles found'); + return []; +} + +module.exports = { + discoverDevices, +}; diff --git a/server/services/tessie/lib/tessie.getConfiguration.js b/server/services/tessie/lib/tessie.getConfiguration.js new file mode 100644 index 0000000000..525385b206 --- /dev/null +++ b/server/services/tessie/lib/tessie.getConfiguration.js @@ -0,0 +1,27 @@ +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const logger = require('../../../utils/logger'); +const { GLADYS_VARIABLES, STATUS } = require('./utils/tessie.constants'); + +/** + * @description Loads Tessie stored configuration. + * @returns {Promise} Tessie configuration. + * @example + * await getConfiguration(); + */ +async function getConfiguration() { + logger.debug('Loading Tessie configuration...'); + const { serviceId } = this; + try { + this.configuration.apiKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.API_KEY, serviceId); + this.configuration.websocketEnabled = await this.gladys.variable.getValue(GLADYS_VARIABLES.WEBSOCKET_ENABLED, serviceId); + logger.debug('Tessie configuration loaded successfully'); + return this.configuration; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } +} + +module.exports = { + getConfiguration, +}; diff --git a/server/services/tessie/lib/tessie.getStatus.js b/server/services/tessie/lib/tessie.getStatus.js new file mode 100644 index 0000000000..055fd94651 --- /dev/null +++ b/server/services/tessie/lib/tessie.getStatus.js @@ -0,0 +1,18 @@ +/** + * @description Get Tessie status. + * @returns {object} Current Tessie 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/tessie/lib/tessie.init.js b/server/services/tessie/lib/tessie.init.js new file mode 100644 index 0000000000..b889df8970 --- /dev/null +++ b/server/services/tessie/lib/tessie.init.js @@ -0,0 +1,23 @@ +/** + * @description Initialize service with properties and connect to devices. + * @example tessie.init(); + */ +async function init() { + await this.getConfiguration(); + this.configured = true; + await this.connect(); + + if (this.vehicles && this.vehicles.length > 0) { + await this.refreshTessieValues(); + await this.startPolling(); + + // Initialiser les connexions WebSocket si activé + if (this.configuration.websocketEnabled && this.configuration.apiKey) { + await this.initWebSocketConnections(); + } + } +} + +module.exports = { + init, +}; diff --git a/server/services/tessie/lib/tessie.loadVehicles.js b/server/services/tessie/lib/tessie.loadVehicles.js new file mode 100644 index 0000000000..6af5303b1a --- /dev/null +++ b/server/services/tessie/lib/tessie.loadVehicles.js @@ -0,0 +1,72 @@ +const Promise = require('bluebird'); +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API } = require('./utils/tessie.constants'); + +/** + * @description Load Tessie vehicles. + * @returns {Promise} List of vehicles. + * @example + * await loadVehicles(); + */ +async function loadVehicles() { + logger.debug('Loading Tessie vehicles...'); + let vehicles = []; + + try { + const response = await fetch(API.VEHICLES, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Tessie API error:', response.status, errorText); + throw new Error(`Tessie API error: ${response.status}`); + } + + const data = await response.json(); + vehicles = data.results || []; + + // // Load additional details for each vehicle + // vehicles = await Promise.all( + // vehicles.map(async (vehicle) => { + // try { + // const stateResponse = await fetch(`${API.VEHICLES}/${vehicle.vin}/${API.VEHICLE_STATE}`, { + // method: 'GET', + // headers: { + // Authorization: `Bearer ${this.configuration.apiKey}`, + // 'Content-Type': API.HEADER.CONTENT_TYPE, + // Accept: API.HEADER.ACCEPT, + // }, + // }); + + // if (stateResponse.ok) { + // const stateData = await stateResponse.json(); + // return { + // ...vehicle, + // state: stateData, + // }; + // } + // } catch (e) { + // logger.error(`Error loading state for vehicle ${vehicle.id}:`, e); + // } + // return vehicle; + // }) + // ); + + logger.debug(`${vehicles.length} Tessie vehicles loaded`); + return vehicles; + } catch (e) { + logger.error('Error loading Tessie vehicles:', e); + throw e; + } +} + +module.exports = { + loadVehicles, +}; diff --git a/server/services/tessie/lib/tessie.pollRefreshingValues.js b/server/services/tessie/lib/tessie.pollRefreshingValues.js new file mode 100644 index 0000000000..ce48aacb98 --- /dev/null +++ b/server/services/tessie/lib/tessie.pollRefreshingValues.js @@ -0,0 +1,102 @@ +const Promise = require('bluebird'); +const { STATUS } = require('./utils/tessie.constants'); +const logger = require('../../../utils/logger'); + +/** + * @description Poll values of Tessie devices. + * @example refreshTessieValues(); + */ +async function refreshTessieValues() { + logger.debug('Looking for Tessie devices values...'); + await this.saveStatus({ statusType: STATUS.GET_DEVICES_VALUES, message: null }); + + let devicesTessie = []; + try { + devicesTessie = await this.loadVehicles(); + } catch (e) { + await this.saveStatus({ + statusType: STATUS.ERROR.GET_DEVICES_VALUES, + message: 'get_devices_value_fail', + }); + logger.error('Unable to load Tessie devices', e); + } + await Promise.map( + devicesTessie, + async (device) => { + const externalId = `tessie:${device.vin}`; + const deviceExistInGladys = await this.gladys.stateManager.get('deviceByExternalId', externalId); + if (deviceExistInGladys) { + await this.updateValues(deviceExistInGladys, device, externalId, device.vin); + } else { + logger.info(`device ${externalId} does not exist in Gladys`); + } + }, + { concurrency: 2 }, + ); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); +} + +/** + * @description Poll values of Tessie devices with dynamic interval. + * @param {number} interval - Polling interval in milliseconds. + * @example pollRefreshingValues(60000); + */ +function pollRefreshingValues(interval = 60 * 1000) { + // Arrêter le polling existant s'il y en a un + if (this.pollRefreshValues) { + clearInterval(this.pollRefreshValues); + } + + this.pollRefreshValues = setInterval(async () => { + try { + await refreshTessieValues.call(this); + // Vérifier l'état du véhicule après chaque refresh pour ajuster l'intervalle + await adjustPollingInterval.call(this); + } catch (error) { + logger.error('Error refreshing Tessie values: ', error); + } + }, interval); + + logger.debug(`Polling started with interval: ${interval}ms`); +} + +/** + * @description Start polling with initial interval and dynamic adjustment. + * @example startPolling(); + */ +async function startPolling() { + // Démarrer avec l'intervalle par défaut (60s) + pollRefreshingValues.call(this, 10 * 1000); +} + +/** + * @description Adjust polling interval based on vehicle state. + * @example adjustPollingInterval(); + */ +async function adjustPollingInterval() { + const fastInterval = 5 * 1000; // 5 secondes + const normalInterval = 5 * 60 * 1000; // 60 secondes + console.log('this.stateVehicle', this.stateVehicle); + // Vérifier si le véhicule est en train de charger ou de conduire + if (this.stateVehicle === STATUS.VEHICLE_STATE.CHARGING || + this.stateVehicle === STATUS.VEHICLE_STATE.DRIVING && !this.configuration.websocketEnabled) { + // Si on n'est pas déjà en mode rapide, redémarrer avec l'intervalle rapide et si websocket est désactivé + if (this.currentPollingInterval !== fastInterval) { + logger.debug(`Vehicle state changed to ${this.stateVehicle}, switching to fast polling (5s)`); + this.currentPollingInterval = fastInterval; + pollRefreshingValues.call(this, fastInterval); + } + } else { + // Si on n'est pas déjà en mode normal, redémarrer avec l'intervalle normal + if (this.currentPollingInterval !== normalInterval) { + logger.debug(`Vehicle state changed to ${this.stateVehicle}, switching to normal polling (60s)`); + this.currentPollingInterval = normalInterval; + pollRefreshingValues.call(this, normalInterval); + } + } +} + +module.exports = { + refreshTessieValues, + startPolling, +}; diff --git a/server/services/tessie/lib/tessie.saveConfiguration.js b/server/services/tessie/lib/tessie.saveConfiguration.js new file mode 100644 index 0000000000..ceff7df995 --- /dev/null +++ b/server/services/tessie/lib/tessie.saveConfiguration.js @@ -0,0 +1,30 @@ +const logger = require('../../../utils/logger'); + +const { GLADYS_VARIABLES } = require('./utils/tessie.constants'); + +/** + * @description Save Tessie configuration. + * @param {object} configuration - Configuration to save. + * @returns {Promise} Tessie well save configuration. + * @example + * await saveConfiguration({ apiKey: '...' }); + */ +async function saveConfiguration(configuration) { + logger.debug('Saving Tessie configuration...'); + const { apiKey, websocketEnabled } = configuration; + try { + await this.gladys.variable.setValue(GLADYS_VARIABLES.API_KEY, apiKey, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.WEBSOCKET_ENABLED, websocketEnabled, this.serviceId); + this.configuration.apiKey = apiKey; + this.configuration.websocketEnabled = websocketEnabled; + logger.debug('Tessie configuration well stored'); + return true; + } catch (e) { + logger.error('Tessie configuration stored errored', e); + return false; + } +} + +module.exports = { + saveConfiguration, +}; diff --git a/server/services/tessie/lib/tessie.saveStatus.js b/server/services/tessie/lib/tessie.saveStatus.js new file mode 100644 index 0000000000..7f1af5e30d --- /dev/null +++ b/server/services/tessie/lib/tessie.saveStatus.js @@ -0,0 +1,140 @@ +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/tessie.constants'); + +/** + * @description Post Tessie status. + * @param {object} status - Configuration to save. + * @returns {object} Current Tessie network status. + * @example + * status({statusType: 'connecting', message: 'invalid_client'}); + */ +function saveStatus(status) { + logger.debug('Changing status Tessie ...'); + 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.TESSIE.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.TESSIE.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.TESSIE.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.TESSIE.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.TESSIE.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.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.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; + + case STATUS.WEBSOCKET_CONNECTING: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, + payload: { status: STATUS.WEBSOCKET_CONNECTING }, + }); + break; + case STATUS.WEBSOCKET_CONNECTED: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, + payload: { status: STATUS.WEBSOCKET_CONNECTED }, + }); + break; + case STATUS.WEBSOCKET_DISCONNECTED: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, + payload: { status: STATUS.WEBSOCKET_DISCONNECTED }, + }); + break; + case STATUS.ERROR.WEBSOCKET: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.WEBSOCKET, + payload: { statusType: STATUS.ERROR.WEBSOCKET, status: status.message }, + }); + break; + default: + this.status = status.statusType; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, + payload: { status: status.statusType }, + }); + break; + } + logger.debug('Status Tessie well changed'); + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, + payload: { status: this.status }, + }); + return true; + } catch (e) { + return false; + } +} +module.exports = { + saveStatus, +}; diff --git a/server/services/tessie/lib/tessie.setValue.js b/server/services/tessie/lib/tessie.setValue.js new file mode 100644 index 0000000000..fd9525c130 --- /dev/null +++ b/server/services/tessie/lib/tessie.setValue.js @@ -0,0 +1,90 @@ +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API, STATUS, PARAMS } = require('./utils/tessie.constants'); +const { BadParameters } = require('../../../utils/coreErrors'); +const { writeValues } = require('./device/tessie.deviceMapping'); + +/** + * @description Set a value to a Tessie vehicle feature. + * @param {object} device - The device to control. + * @param {object} feature - The feature to control. + * @param {any} value - The value to set. + * @returns {Promise} The result of the command. + * @example + * await setValue(device, feature, value); + */ +async function setValue(device, feature, value) { + logger.debug(`Setting value ${value} to feature ${feature.selector} of device ${device.selector}`); + + const vehicleId = device.params.find((param) => param.name === 'vehicle_id')?.value; + if (!vehicleId) { + throw new Error('Vehicle ID not found in device params'); + } + + try { + let endpoint = ''; + let body = {}; + + // Déterminer la commande à envoyer en fonction du type de feature + switch (feature.type) { + case 'binary': + if (feature.name === 'Charging State') { + endpoint = `${API.VEHICLES}/${vehicleId}${API.VEHICLE_COMMAND}`; + body = { + command: value === 1 ? 'start_charging' : 'stop_charging', + }; + } + break; + + case 'enum': + if (feature.name === 'Vehicle State') { + endpoint = `${API.VEHICLES}/${vehicleId}${API.VEHICLE_COMMAND}`; + switch (value) { + case 'online': + body = { command: 'wake' }; + break; + case 'offline': + body = { command: 'sleep' }; + break; + default: + throw new Error(`Unsupported vehicle state: ${value}`); + } + } + break; + + default: + throw new Error(`Unsupported feature type: ${feature.type}`); + } + + if (!endpoint) { + throw new Error(`No command endpoint found for feature: ${feature.name}`); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Tessie API error:', response.status, errorText); + throw new Error(`Tessie API error: ${response.status}`); + } + + const result = await response.json(); + logger.debug(`Command executed successfully: ${JSON.stringify(result)}`); + return result; + } catch (e) { + logger.error(`Error setting value to feature ${feature.selector}:`, e); + throw e; + } +} + +module.exports = { + setValue, +}; diff --git a/server/services/tessie/lib/tessie.updateValues.js b/server/services/tessie/lib/tessie.updateValues.js new file mode 100644 index 0000000000..8e1be75395 --- /dev/null +++ b/server/services/tessie/lib/tessie.updateValues.js @@ -0,0 +1,78 @@ +const { BadParameters } = require('../../../utils/coreErrors'); +const logger = require('../../../utils/logger'); +const { API, BASE_API, STATUS } = require('./utils/tessie.constants'); + +/** + * @description Update all vehicle values. + * @returns {Promise} The result of the update. + * @example + * await updateValues(); + */ +async function updateValues(device, vehicle, externalId, vin) { + logger.debug('Updating Tessie vehicle values...'); + + try { + // Get vehicle states + const vehicleStateResponse = await fetch(`${BASE_API}/${vin}${API.VEHICLE_STATE}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + + if (vehicleStateResponse.status !== 200) { + logger.error(`Error getting state for vehicle ${vin}:`, await vehicleStateResponse.text()); + return; + } + + const vehicleState = await vehicleStateResponse.json(); + + const { charge_state: chargeState, drive_state: driveState } = vehicleState; + if ( + chargeState.charging_state === 'Disconnected' && + driveState.shift_state === 'P' && + this.stateVehicle !== STATUS.VEHICLE_STATE.PARKING + ) { + this.stateVehicle = STATUS.VEHICLE_STATE.PARKING; + } else if (chargeState.charging_state === 'Charging' && this.stateVehicle !== STATUS.VEHICLE_STATE.CHARGING) { + this.stateVehicle = STATUS.VEHICLE_STATE.CHARGING; + } else if (driveState.shift_state !== 'P' && this.stateVehicle !== STATUS.VEHICLE_STATE.DRIVING) { + this.stateVehicle = STATUS.VEHICLE_STATE.DRIVING; + } + + // Update features in Gladys + if (device) { + // Update battery + await this.updateBattery(device, vehicleState, vin, externalId); + + // Update charge + await this.updateCharge(device, vehicleState, vin, externalId); + + // Update climate + await this.updateClimate(device, vehicleState, externalId); + + // Update command + await this.updateCommand(device, vehicleState, externalId); + + // Update consumption + await this.updateConsumption(device, vin, externalId); + + // Update drive + await this.updateDrive(device, vehicleState, vin, externalId); + + // Update state + await this.updateState(device, vehicleState, externalId); + + } + } catch (e) { + logger.error(`Error updating vehicle ${vehicle.id}:`, e); + } + + logger.debug('Tessie vehicle values updated successfully'); +} + +module.exports = { + updateValues, +}; diff --git a/server/services/tessie/lib/tessie.websocket.js b/server/services/tessie/lib/tessie.websocket.js new file mode 100644 index 0000000000..498dfb64a7 --- /dev/null +++ b/server/services/tessie/lib/tessie.websocket.js @@ -0,0 +1,77 @@ +const WebSocket = require('ws'); +const logger = require('../../../utils/logger'); +const { WEBSOCKET, STATUS } = require('./utils/tessie.constants'); +const handleWebSocketMessage = require('./websocket/handleWebSocketMessage'); +const updateValuesFromWebSocket = require('./websocket/updateValuesFromWebSocket'); +const getWebSocketFeatureMapping = require('./websocket/getWebSocketFeatureMapping'); +const parseWebSocketValue = require('./websocket/parseWebSocketValue'); +const shouldUpdateFeature = require('./utils/shouldUpdateFeature'); +const reconnectWebSocket = require('./websocket/reconnectWebSocket'); +const disconnectAllWebSockets = require('./websocket/disconnectAllWebSockets'); +const initWebSocketConnections = require('./websocket/initWebSocketConnections'); + +/** + * @description Connect to Tessie WebSocket for a specific vehicle. + * @param {string} vin - Vehicle VIN. + * @param {string} apiKey - Tessie API key. + * @returns {Promise} WebSocket connection. + * @example + * await connectWebSocket('LRW3F7FR9NC123456', 'api_key_here'); + */ +async function connectWebSocket(vin, apiKey) { + const url = `${WEBSOCKET.BASE_URL}/${vin}?access_token=${apiKey}`; + logger.debug(`Connecting to Tessie WebSocket for VIN: ${vin}`); + + await this.saveStatus({ statusType: STATUS.WEBSOCKET_CONNECTING, message: null }); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.on('open', () => { + logger.debug(`WebSocket connected for VIN: ${vin}`); + this.websocketConnections.set(vin, ws); + this.saveStatus({ statusType: STATUS.WEBSOCKET_CONNECTED, message: null }); + resolve(ws); + }); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.handleWebSocketMessage(vin, message); + } catch (error) { + logger.error(`Error parsing WebSocket message for VIN ${vin}:`, error); + } + }); + + ws.on('error', (error) => { + logger.error(`WebSocket error for VIN ${vin}:`, error); + this.saveStatus({ statusType: STATUS.ERROR.WEBSOCKET, message: error.message }); + reject(error); + }); + + ws.on('close', (code, reason) => { + logger.debug(`WebSocket closed for VIN ${vin}: ${code} - ${reason}`); + this.websocketConnections.delete(vin); + this.saveStatus({ statusType: STATUS.WEBSOCKET_DISCONNECTED, message: null }); + + // Tentative de reconnexion si activé + if (this.configuration.websocketEnabled) { + setTimeout(() => { + this.reconnectWebSocket(vin, apiKey); + }, WEBSOCKET.RECONNECT_INTERVAL); + } + }); + }); +} + +module.exports = { + connectWebSocket, + handleWebSocketMessage, + updateValuesFromWebSocket, + getWebSocketFeatureMapping, + parseWebSocketValue, + shouldUpdateFeature, + reconnectWebSocket, + disconnectAllWebSockets, + initWebSocketConnections, +}; \ No newline at end of file diff --git a/server/services/tessie/lib/update/tessie.updateBattery.js b/server/services/tessie/lib/update/tessie.updateBattery.js new file mode 100644 index 0000000000..355b9e3a1f --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateBattery.js @@ -0,0 +1,141 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { BASE_API, API, UPDATE_THRESHOLDS } = require('../utils/tessie.constants'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} vehicle - Vehicle object coming from the Tessie API. + * @param {string} vin - Vehicle identifier in Tessie. + * @param {string} externalId - Device identifier in gladys. + * @example updateBattery(deviceGladys, vehicle, externalId); + */ +async function updateBattery(deviceGladys, vehicle, vin, externalId) { + const { charge_state: chargeState, drive_state: driveState } = vehicle; + + // Get vehicle battery + const batteryResponse = await fetch(`${BASE_API}/${vin}${API.VEHICLE_BATTERY}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + if (batteryResponse.status !== 200) { + logger.error(`Error getting battery for vehicle ${vin}:`, await batteryResponse.text()); + return; + } + const battery = await batteryResponse.json(); + + const batteryLevelFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:battery_level`); + const batteryEnergyRemainingFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:battery_energy_remaining`, + ); + const batteryRangeEstimateFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:battery_range_estimate`, + ); + const batteryTemperatureMinFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:battery_temperature_min`, + ); + const batteryTemperatureMaxFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:battery_temperature_max`, + ); + const batteryVoltageFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:battery_voltage`); + const batteryPowerFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:battery_power`); + try { + if (chargeState) { + if (batteryLevelFeature) { + const newValue = chargeState.usable_battery_level || battery.battery_level; + if (shouldUpdateFeature(batteryLevelFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryLevelFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_level: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_level: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (batteryEnergyRemainingFeature) { + const newValue = battery.energy_remaining; + if (shouldUpdateFeature(batteryEnergyRemainingFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryEnergyRemainingFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_energy_remaining: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_energy_remaining: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (batteryRangeEstimateFeature) { + const newValue = battery.battery_range; + if (shouldUpdateFeature(batteryRangeEstimateFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryRangeEstimateFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_range_estimate: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_range_estimate: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (batteryPowerFeature) { + const newValue = driveState.power; + if (shouldUpdateFeature(batteryPowerFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryPowerFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_power: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_power: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (batteryTemperatureMinFeature) { + const newValue = battery.module_temp_min; + if (shouldUpdateFeature(batteryTemperatureMinFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryTemperatureMinFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_temperature_min: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_temperature_min: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (batteryTemperatureMaxFeature) { + const newValue = battery.module_temp_max; + if (shouldUpdateFeature(batteryTemperatureMaxFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryTemperatureMaxFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_temperature_max: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_temperature_max: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (batteryVoltageFeature) { + const newValue = battery.pack_voltage; + if (shouldUpdateFeature(batteryVoltageFeature, newValue, UPDATE_THRESHOLDS.BATTERY)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: batteryVoltageFeature.external_id, + state: newValue, + }); + logger.debug(`Updated battery_voltage: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped battery_voltage: value unchanged (${newValue}) for VIN ${vin}`); + } + } + } + } catch (e) { + logger.error('deviceGladys Battery: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateBattery, +}; diff --git a/server/services/tessie/lib/update/tessie.updateCharge.js b/server/services/tessie/lib/update/tessie.updateCharge.js new file mode 100644 index 0000000000..2016e90c39 --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateCharge.js @@ -0,0 +1,217 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { BASE_API, API } = require('../utils/tessie.constants'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} vehicle - Vehicle object coming from the Tessie API. + * @param {string} vin - Vehicle identifier in Tessie. + * @param {string} externalId - Device identifier in gladys. + * @example updateCharge(deviceGladys, vehicle, externalId); + */ +async function updateCharge(deviceGladys, vehicle, vin, externalId) { + const { charge_state: chargeState } = vehicle; + + // Get vehicle charges + const queryParams = { + distance_format: 'mi', + timezone: 'UTC', + superchargers_only: 'false', + exclude_origin: 'false', + format: 'json', + }; + const queryString = new URLSearchParams(queryParams).toString(); + const chargeResponse = await fetch(`${BASE_API}/${vin}${API.VEHICLE_CHARGING}?${queryString}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + if (chargeResponse.status !== 200) { + logger.error(`Error getting charge for vehicle ${vin}:`, await chargeResponse.text()); + return; + } + const charges = await chargeResponse.json(); + + const chargeCurrentFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:charge_current`); + const chargeEnergyAddedTotalFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:charge_energy_added_total`, + ); + const chargeEnergyConsumptionTotalFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:charge_energy_consumption_total`, + ); + const chargeOnStateFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:charge_on`); + const chargePowerFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:charge_power`); + const chargeVoltageFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:charge_voltage`); + const chargeLastChargeEnergyAddedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_charge_energy_added`, + ); + const chargeLastChargeEnergyConsumptionFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_charge_energy_consumption`, + ); + const chargePluggedFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:plugged`); + const chargeTargetChargeLimitFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:target_charge_limit`, + ); + const chargeTargetCurrentFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:target_current`, + ); + + try { + let lastCharge = null; + let totalChargeEnergyAdded = 0; + let totalChargeEnergyConsumption = 0; + if (charges.results.length > 0) { + lastCharge = charges.results[0]; + + charges.results.forEach((charge) => { + totalChargeEnergyAdded += charge.energy_added; + totalChargeEnergyConsumption += charge.energy_used; + }); + } + if (chargeState) { + if (chargeCurrentFeature) { + const newValue = chargeState.charger_actual_current; + if (shouldUpdateFeature(chargeCurrentFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeCurrentFeature.external_id, + state: newValue, + }); + logger.debug(`Updated charge_current: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped charge_current: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeEnergyAddedTotalFeature) { + const newValue = totalChargeEnergyAdded; + if (shouldUpdateFeature(chargeEnergyAddedTotalFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeEnergyAddedTotalFeature.external_id, + state: newValue, + }); + logger.debug(`Updated charge_energy_added_total: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped charge_energy_added_total: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeEnergyConsumptionTotalFeature) { + const newValue = totalChargeEnergyConsumption; + if (shouldUpdateFeature(chargeEnergyConsumptionTotalFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeEnergyConsumptionTotalFeature.external_id, + state: newValue, + }); + logger.debug(`Updated charge_energy_consumption_total: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped charge_energy_consumption_total: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeOnStateFeature) { + const newValue = chargeState.charging_state === 'Charging' ? 1 : 0; + if (shouldUpdateFeature(chargeOnStateFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeOnStateFeature.external_id, + state: newValue, + }); + logger.debug(`Updated charge_on: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped charge_on: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargePowerFeature) { + const newValue = chargeState.charger_power; + if (shouldUpdateFeature(chargePowerFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargePowerFeature.external_id, + state: newValue, + }); + logger.debug(`Updated charge_power: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped charge_power: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeVoltageFeature) { + const newValue = chargeState.charger_voltage; + if (shouldUpdateFeature(chargeVoltageFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeVoltageFeature.external_id, + state: newValue, + }); + logger.debug(`Updated charge_voltage: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped charge_voltage: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeLastChargeEnergyAddedFeature && lastCharge) { + const newValue = lastCharge.energy_added; + if (shouldUpdateFeature(chargeLastChargeEnergyAddedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeLastChargeEnergyAddedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated last_charge_energy_added: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_charge_energy_added: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeLastChargeEnergyConsumptionFeature && lastCharge) { + const newValue = lastCharge.energy_used; + if (shouldUpdateFeature(chargeLastChargeEnergyConsumptionFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeLastChargeEnergyConsumptionFeature.external_id, + state: newValue, + }); + logger.debug(`Updated last_charge_energy_consumption: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_charge_energy_consumption: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargePluggedFeature) { + const newValue = chargeState.charging_state !== 'Disconnected' ? 1 : 0; + if (shouldUpdateFeature(chargePluggedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargePluggedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated plugged: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped plugged: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeTargetChargeLimitFeature) { + const newValue = chargeState.charge_limit_soc; + if (shouldUpdateFeature(chargeTargetChargeLimitFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeTargetChargeLimitFeature.external_id, + state: newValue, + }); + logger.debug(`Updated target_charge_limit: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped target_charge_limit: value unchanged (${newValue}) for VIN ${vin}`); + } + } + if (chargeTargetCurrentFeature) { + const newValue = chargeState.charge_current_request; + if (shouldUpdateFeature(chargeTargetCurrentFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: chargeTargetCurrentFeature.external_id, + state: newValue, + }); + logger.debug(`Updated target_current: ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped target_current: value unchanged (${newValue}) for VIN ${vin}`); + } + } + } + } catch (e) { + logger.error('deviceGladys Charge: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateCharge, +}; diff --git a/server/services/tessie/lib/update/tessie.updateClimate.js b/server/services/tessie/lib/update/tessie.updateClimate.js new file mode 100644 index 0000000000..3daeea98af --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateClimate.js @@ -0,0 +1,84 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} vehicle - Vehicle object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateClimate(deviceGladys, vehicle, externalId); + */ +async function updateClimate(deviceGladys, vehicle, externalId) { + const { climate_state: climateState } = vehicle; + + const climateOnFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:climate_on`); + const indoorTemperatureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:indoor_temperature`, + ); + const outsideTemperatureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:outside_temperature`, + ); + const targetTemperatureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:target_temperature`, + ); + + try { + if (climateState) { + if (climateOnFeature) { + const newValue = climateState.is_climate_on ? 1 : 0; + if (shouldUpdateFeature(climateOnFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: climateOnFeature.external_id, + state: newValue, + }); + logger.debug(`Updated climate_on: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped climate_on: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (indoorTemperatureFeature) { + const newValue = climateState.inside_temp; + if (shouldUpdateFeature(indoorTemperatureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: indoorTemperatureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated indoor_temperature: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped indoor_temperature: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (outsideTemperatureFeature) { + const newValue = climateState.outside_temp; + if (shouldUpdateFeature(outsideTemperatureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: outsideTemperatureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated outside_temperature: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped outside_temperature: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (targetTemperatureFeature) { + const newValue = climateState.driver_temp_setting; + if (shouldUpdateFeature(targetTemperatureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: targetTemperatureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated target_temperature: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped target_temperature: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + } + } catch (e) { + logger.error('deviceGladys Climate: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateClimate, +}; diff --git a/server/services/tessie/lib/update/tessie.updateCommand.js b/server/services/tessie/lib/update/tessie.updateCommand.js new file mode 100644 index 0000000000..c763ad9c47 --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateCommand.js @@ -0,0 +1,52 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} vehicle - Vehicle object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateCommand(deviceGladys, vehicle, externalId); + */ +async function updateCommand(deviceGladys, vehicle, externalId) { + const { vehicle_state: vehicleState } = vehicle; + + const alarmFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:alarm`); + const lockFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:lock`); + + try { + if (vehicleState) { + if (alarmFeature) { + const newValue = vehicleState.sentry_mode ? 1 : 0; + if (shouldUpdateFeature(alarmFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: alarmFeature.external_id, + state: newValue, + }); + logger.debug(`Updated alarm: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped alarm: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (lockFeature) { + const newValue = vehicleState.locked ? 1 : 0; + if (shouldUpdateFeature(lockFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lockFeature.external_id, + state: newValue, + }); + logger.debug(`Updated lock: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped lock: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + } + } catch (e) { + logger.error('deviceGladys Command: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateCommand, +}; diff --git a/server/services/tessie/lib/update/tessie.updateConsumption.js b/server/services/tessie/lib/update/tessie.updateConsumption.js new file mode 100644 index 0000000000..31da897875 --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateConsumption.js @@ -0,0 +1,141 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { BASE_API, API } = require('../utils/tessie.constants'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {string} vin - Vehicle identifier in Tessie. + * @param {string} externalId - Device identifier in gladys. + * @example updateConsumption(deviceGladys, vehicle, externalId); + */ +async function updateConsumption(deviceGladys, vin, externalId) { + + // Get vehicle consumption + const consumptionResponse = await fetch(`${BASE_API}/${vin}${API.VEHICLE_CONSUMPTION}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + if (consumptionResponse.status !== 200) { + logger.error(`Error getting consumption for vehicle ${vin}:`, await consumptionResponse.text()); + return; + } + const consumption = await consumptionResponse.json(); + + const consumptionEnergyConsumption100MiFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:energy_consumption_100mile`, + ); + const consumptionEnergyConsumption100MiByDrivingFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:energy_consumption_100mile_by_driving`, + ); + const consumptionEnergyConsumptionMiFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:energy_consumption_mile`, + ); + const consumptionEnergyConsumptionMiByDrivingFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:energy_consumption_mile_by_driving`, + ); + const consumptionEnergyEfficiencyFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:energy_efficiency`, + ); + const consumptionEnergyEfficiencyByDrivingFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:energy_efficiency_by_driving`, + ); + + try { + if (consumption) { + const { + rated_range_used, + rated_range_used_by_driving, + ideal_range_used, + ideal_range_used_by_driving, + distance_driven, + energy_used, + energy_used_by_driving, + } = consumption; + const energyConsumption100mi = (energy_used / rated_range_used) * 100 || (energy_used / ideal_range_used) * 100; + const energyConsumption100miByDriving = (energy_used_by_driving / rated_range_used_by_driving) * 100 || (energy_used_by_driving / ideal_range_used_by_driving) * 100; + const energyConsumptionMi = (energy_used * 1000) / distance_driven || (energy_used * 1000) / distance_driven; + const energyConsumptionMiByDriving = (energy_used_by_driving * 1000) / distance_driven || (energy_used_by_driving * 1000) / distance_driven; + const energyEfficiency = distance_driven / energy_used || distance_driven / energy_used; + const energyEfficiencyByDriving = distance_driven / energy_used_by_driving || distance_driven / energy_used_by_driving; + + if (consumptionEnergyConsumption100MiFeature) { + if (shouldUpdateFeature(consumptionEnergyConsumption100MiFeature, energyConsumption100mi)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: consumptionEnergyConsumption100MiFeature.external_id, + state: energyConsumption100mi, + }); + logger.debug(`Updated energy_consumption_100mile: ${energyConsumption100mi} for VIN ${vin}`); + } else { + logger.debug(`Skipped energy_consumption_100mile: value unchanged (${energyConsumption100mi}) for VIN ${vin}`); + } + } + if (consumptionEnergyConsumption100MiByDrivingFeature) { + if (shouldUpdateFeature(consumptionEnergyConsumption100MiByDrivingFeature, energyConsumption100miByDriving)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: consumptionEnergyConsumption100MiByDrivingFeature.external_id, + state: energyConsumption100miByDriving, + }); + logger.debug(`Updated energy_consumption_100mile_by_driving: ${energyConsumption100miByDriving} for VIN ${vin}`); + } else { + logger.debug(`Skipped energy_consumption_100mile_by_driving: value unchanged (${energyConsumption100miByDriving}) for VIN ${vin}`); + } + } + if (consumptionEnergyConsumptionMiFeature) { + if (shouldUpdateFeature(consumptionEnergyConsumptionMiFeature, energyConsumptionMi)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: consumptionEnergyConsumptionMiFeature.external_id, + state: energyConsumptionMi, + }); + logger.debug(`Updated energy_consumption_mile: ${energyConsumptionMi} for VIN ${vin}`); + } else { + logger.debug(`Skipped energy_consumption_mile: value unchanged (${energyConsumptionMi}) for VIN ${vin}`); + } + } + if (consumptionEnergyConsumptionMiByDrivingFeature) { + if (shouldUpdateFeature(consumptionEnergyConsumptionMiByDrivingFeature, energyConsumptionMiByDriving)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: consumptionEnergyConsumptionMiByDrivingFeature.external_id, + state: energyConsumptionMiByDriving, + }); + logger.debug(`Updated energy_consumption_mile_by_driving: ${energyConsumptionMiByDriving} for VIN ${vin}`); + } else { + logger.debug(`Skipped energy_consumption_mile_by_driving: value unchanged (${energyConsumptionMiByDriving}) for VIN ${vin}`); + } + } + if (consumptionEnergyEfficiencyFeature) { + if (shouldUpdateFeature(consumptionEnergyEfficiencyFeature, energyEfficiency)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: consumptionEnergyEfficiencyFeature.external_id, + state: energyEfficiency, + }); + logger.debug(`Updated energy_efficiency: ${energyEfficiency} for VIN ${vin}`); + } else { + logger.debug(`Skipped energy_efficiency: value unchanged (${energyEfficiency}) for VIN ${vin}`); + } + } + if (consumptionEnergyEfficiencyByDrivingFeature) { + if (shouldUpdateFeature(consumptionEnergyEfficiencyByDrivingFeature, energyEfficiencyByDriving)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: consumptionEnergyEfficiencyByDrivingFeature.external_id, + state: energyEfficiencyByDriving, + }); + logger.debug(`Updated energy_efficiency_by_driving: ${energyEfficiencyByDriving} for VIN ${vin}`); + } else { + logger.debug(`Skipped energy_efficiency_by_driving: value unchanged (${energyEfficiencyByDriving}) for VIN ${vin}`); + } + } + } + } catch (e) { + logger.error('deviceGladys Consumption: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateConsumption, +}; diff --git a/server/services/tessie/lib/update/tessie.updateDrive.js b/server/services/tessie/lib/update/tessie.updateDrive.js new file mode 100644 index 0000000000..3dafe6683e --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateDrive.js @@ -0,0 +1,210 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { BASE_API, API, STATUS, UPDATE_THRESHOLDS } = require('../utils/tessie.constants'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} vehicle - Vehicle object coming from the Tessie API. + * @param {string} vin - Vehicle identifier in Tessie. + * @param {string} externalId - Device identifier in gladys. + * @example updateDrive(deviceGladys, vehicle, externalId); + */ +async function updateDrive(deviceGladys, vehicle, vin, externalId) { + const { drive_state: driveState } = vehicle; + // Get vehicle charges + let drives = null; + if (this.stateVehicle === STATUS.VEHICLE_STATE.DRIVING) { + const queryParams = { + distance_format: 'mi', + temperature_format: 'c', + timezone: 'UTC', + exclude_origin: 'false', + exclude_destination: 'false', + exclude_tags: 'false', + exclude_driver_profiles: 'false', + format: 'json', + }; + const queryString = new URLSearchParams(queryParams).toString(); + const driveResponse = await fetch(`${BASE_API}/${vin}${API.VEHICLE_DRIVES}?${queryString}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.configuration.apiKey}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + if (driveResponse.status !== 200) { + logger.error(`Error getting drives for vehicle ${vin}:`, await driveResponse.text()); + return; + } + drives = await driveResponse.json(); + } + + const driveEnergyConsumptionTotalFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:drive_energy_consumption_total`, + ); + const driveSpeedFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:speed`); + const lastDriveEnergyConsumptionFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_energy_consumption`, + ); + const lastDriveDistanceFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_distance`, + ); + const lastDriveAverageSpeedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_average_speed`, + ); + const lastDriveMaxSpeedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_max_speed`, + ); + const lastDriveAverageInsideTemperatureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_average_inside_temperature`, + ); + const lastDriveAverageOutsideTemperatureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_average_outside_temperature`, + ); + const lastDriveStartingBatteryFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_starting_battery`, + ); + const lastDriveEndingBatteryFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:last_drive_ending_battery`, + ); + + try { + + if (driveState) { + if (driveSpeedFeature) { + if (shouldUpdateFeature(driveSpeedFeature, driveState.speed, UPDATE_THRESHOLDS.DRIVE)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: driveSpeedFeature.external_id, + state: driveState.speed, + }); + logger.debug(`Updated speed: ${driveState.speed} for VIN ${vin}`); + } else { + logger.debug(`Skipped speed: value unchanged (${driveState.speed}) for VIN ${vin}`); + } + } + } + + // Update last drive features + if (drives) { + let lastDrive = null; + let totalDriveEnergyConsumption = 0; + if (drives.results.length > 0) { + lastDrive = drives.results[0]; + + drives.results.forEach((drive) => { + totalDriveEnergyConsumption += drive.energy_used; + }); + } + if (driveEnergyConsumptionTotalFeature) { + if (shouldUpdateFeature(driveEnergyConsumptionTotalFeature, totalDriveEnergyConsumption, UPDATE_THRESHOLDS.DRIVE)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: driveEnergyConsumptionTotalFeature.external_id, + state: totalDriveEnergyConsumption, + }); + logger.debug(`Updated drive_energy_consumption_total: ${totalDriveEnergyConsumption} for VIN ${vin}`); + } else { + logger.debug(`Skipped drive_energy_consumption_total: value unchanged (${totalDriveEnergyConsumption}) for VIN ${vin}`); + } + } + if (lastDrive && lastDrive.odometer_distance > 0) { + if (lastDriveEnergyConsumptionFeature) { + if (shouldUpdateFeature(lastDriveEnergyConsumptionFeature, lastDrive.energy_used)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveEnergyConsumptionFeature.external_id, + state: lastDrive.energy_used, + }); + logger.debug(`Updated last_drive_energy_consumption: ${lastDrive.energy_used} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_energy_consumption: value unchanged (${lastDrive.energy_used}) for VIN ${vin}`); + } + } + if (lastDriveDistanceFeature) { + if (shouldUpdateFeature(lastDriveDistanceFeature, lastDrive.odometer_distance)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveDistanceFeature.external_id, + state: lastDrive.odometer_distance, + }); + logger.debug(`Updated last_drive_distance: ${lastDrive.odometer_distance} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_distance: value unchanged (${lastDrive.odometer_distance}) for VIN ${vin}`); + } + } + if (lastDriveAverageSpeedFeature) { + if (shouldUpdateFeature(lastDriveAverageSpeedFeature, lastDrive.average_speed)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveAverageSpeedFeature.external_id, + state: lastDrive.average_speed, + }); + logger.debug(`Updated last_drive_average_speed: ${lastDrive.average_speed} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_average_speed: value unchanged (${lastDrive.average_speed}) for VIN ${vin}`); + } + } + if (lastDriveMaxSpeedFeature) { + if (shouldUpdateFeature(lastDriveMaxSpeedFeature, lastDrive.max_speed)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveMaxSpeedFeature.external_id, + state: lastDrive.max_speed, + }); + logger.debug(`Updated last_drive_max_speed: ${lastDrive.max_speed} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_max_speed: value unchanged (${lastDrive.max_speed}) for VIN ${vin}`); + } + } + if (lastDriveAverageInsideTemperatureFeature) { + if (shouldUpdateFeature(lastDriveAverageInsideTemperatureFeature, lastDrive.average_inside_temperature)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveAverageInsideTemperatureFeature.external_id, + state: lastDrive.average_inside_temperature, + }); + logger.debug(`Updated last_drive_average_inside_temperature: ${lastDrive.average_inside_temperature} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_average_inside_temperature: value unchanged (${lastDrive.average_inside_temperature}) for VIN ${vin}`); + } + } + if (lastDriveAverageOutsideTemperatureFeature) { + if (shouldUpdateFeature(lastDriveAverageOutsideTemperatureFeature, lastDrive.average_outside_temperature)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveAverageOutsideTemperatureFeature.external_id, + state: lastDrive.average_outside_temperature, + }); + logger.debug(`Updated last_drive_average_outside_temperature: ${lastDrive.average_outside_temperature} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_average_outside_temperature: value unchanged (${lastDrive.average_outside_temperature}) for VIN ${vin}`); + } + } + if (lastDriveStartingBatteryFeature) { + if (shouldUpdateFeature(lastDriveStartingBatteryFeature, lastDrive.starting_battery)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveStartingBatteryFeature.external_id, + state: lastDrive.starting_battery, + }); + logger.debug(`Updated last_drive_starting_battery: ${lastDrive.starting_battery} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_starting_battery: value unchanged (${lastDrive.starting_battery}) for VIN ${vin}`); + } + } + if (lastDriveEndingBatteryFeature) { + if (shouldUpdateFeature(lastDriveEndingBatteryFeature, lastDrive.ending_battery)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: lastDriveEndingBatteryFeature.external_id, + state: lastDrive.ending_battery, + }); + logger.debug(`Updated last_drive_ending_battery: ${lastDrive.ending_battery} for VIN ${vin}`); + } else { + logger.debug(`Skipped last_drive_ending_battery: value unchanged (${lastDrive.ending_battery}) for VIN ${vin}`); + } + } + } + } + } catch (e) { + logger.error('deviceGladys Drive: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateDrive, +}; diff --git a/server/services/tessie/lib/update/tessie.updateState.js b/server/services/tessie/lib/update/tessie.updateState.js new file mode 100644 index 0000000000..aafea15faa --- /dev/null +++ b/server/services/tessie/lib/update/tessie.updateState.js @@ -0,0 +1,247 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const shouldUpdateFeature = require('../utils/shouldUpdateFeature'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} vehicle - Vehicle object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateState(deviceGladys, vehicle, externalId); + */ +async function updateState(deviceGladys, vehicle, externalId) { + const { vehicle_state: vehicleState } = vehicle; + + const frontDriverDoorOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:door_df_opened`, + ); + const frontPassengerDoorOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:door_pf_opened`, + ); + const rearDriverDoorOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:door_dr_opened`, + ); + const rearPassengerDoorOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:door_pr_opened`, + ); + const frunkOpenedFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:door_ft_opened`); + const trunkOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:door_rt_opened`, + ); + const odometerFeature = deviceGladys.features.find((f) => f.external_id === `${externalId}:odometer`); + const frontLeftTirePressureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:tire_pressure_fl`, + ); + const frontRightTirePressureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:tire_pressure_fr`, + ); + const rearLeftTirePressureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:tire_pressure_rl`, + ); + const rearRightTirePressureFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:tire_pressure_rr`, + ); + const frontDriverWindowOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:window_fd_opened`, + ); + const frontPassengerWindowOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:window_fp_opened`, + ); + const rearDriverWindowOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:window_rd_opened`, + ); + const rearPassengerWindowOpenedFeature = deviceGladys.features.find( + (f) => f.external_id === `${externalId}:window_rp_opened`, + ); + + try { + if (vehicleState) { + if (frontDriverDoorOpenedFeature) { + const newValue = vehicleState.df; + if (shouldUpdateFeature(frontDriverDoorOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frontDriverDoorOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated door_df_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped door_df_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (frontPassengerDoorOpenedFeature) { + const newValue = vehicleState.pf; + if (shouldUpdateFeature(frontPassengerDoorOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frontPassengerDoorOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated door_pf_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped door_pf_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (rearDriverDoorOpenedFeature) { + const newValue = vehicleState.dr; + if (shouldUpdateFeature(rearDriverDoorOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: rearDriverDoorOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated door_dr_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped door_dr_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (rearPassengerDoorOpenedFeature) { + const newValue = vehicleState.pr; + if (shouldUpdateFeature(rearPassengerDoorOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: rearPassengerDoorOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated door_pr_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped door_pr_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (frunkOpenedFeature) { + const newValue = vehicleState.ft; + if (shouldUpdateFeature(frunkOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frunkOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated door_ft_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped door_ft_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (trunkOpenedFeature) { + const newValue = vehicleState.rt; + if (shouldUpdateFeature(trunkOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: trunkOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated door_rt_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped door_rt_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (odometerFeature) { + const newValue = vehicleState.odometer; + if (shouldUpdateFeature(odometerFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: odometerFeature.external_id, + state: newValue, + }); + logger.debug(`Updated odometer: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped odometer: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (frontLeftTirePressureFeature) { + const newValue = vehicleState.tpms_pressure_fl; + if (shouldUpdateFeature(frontLeftTirePressureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frontLeftTirePressureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated tire_pressure_fl: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped tire_pressure_fl: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (frontRightTirePressureFeature) { + const newValue = vehicleState.tpms_pressure_fr; + if (shouldUpdateFeature(frontRightTirePressureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frontRightTirePressureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated tire_pressure_fr: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped tire_pressure_fr: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (rearLeftTirePressureFeature) { + const newValue = vehicleState.tpms_pressure_rl; + if (shouldUpdateFeature(rearLeftTirePressureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: rearLeftTirePressureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated tire_pressure_rl: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped tire_pressure_rl: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (rearRightTirePressureFeature) { + const newValue = vehicleState.tpms_pressure_rr; + if (shouldUpdateFeature(rearRightTirePressureFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: rearRightTirePressureFeature.external_id, + state: newValue, + }); + logger.debug(`Updated tire_pressure_rr: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped tire_pressure_rr: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (frontDriverWindowOpenedFeature) { + const newValue = vehicleState.fd_window; + if (shouldUpdateFeature(frontDriverWindowOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frontDriverWindowOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated window_fd_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped window_fd_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (frontPassengerWindowOpenedFeature) { + const newValue = vehicleState.fp_window; + if (shouldUpdateFeature(frontPassengerWindowOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: frontPassengerWindowOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated window_fp_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped window_fp_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (rearDriverWindowOpenedFeature) { + const newValue = vehicleState.rd_window; + if (shouldUpdateFeature(rearDriverWindowOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: rearDriverWindowOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated window_rd_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped window_rd_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + if (rearPassengerWindowOpenedFeature) { + const newValue = vehicleState.rp_window; + if (shouldUpdateFeature(rearPassengerWindowOpenedFeature, newValue)) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: rearPassengerWindowOpenedFeature.external_id, + state: newValue, + }); + logger.debug(`Updated window_rp_opened: ${newValue} for device ${deviceGladys.name}`); + } else { + logger.debug(`Skipped window_rp_opened: value unchanged (${newValue}) for device ${deviceGladys.name}`); + } + } + } + } catch (e) { + logger.error('deviceGladys State: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateState, +}; diff --git a/server/services/tessie/lib/utils/shouldUpdateFeature.js b/server/services/tessie/lib/utils/shouldUpdateFeature.js new file mode 100644 index 0000000000..297345a533 --- /dev/null +++ b/server/services/tessie/lib/utils/shouldUpdateFeature.js @@ -0,0 +1,31 @@ +const { UPDATE_THRESHOLDS } = require('./tessie.constants'); + +/** + * Détermine si une feature doit être mise à jour + * @param {object} feature - La feature Gladys + * @param {any} newValue - La nouvelle valeur + * @param {number} timeThreshold - Seuil de temps en millisecondes + * @returns {boolean} True si la mise à jour est nécessaire + */ +function shouldUpdateFeature(feature, newValue, timeThreshold = UPDATE_THRESHOLDS.DEFAULT) { + const now = Date.now(); + + // Si pas de dernière valeur, toujours mettre à jour + if (feature.last_value === null || feature.last_value === undefined) { + return true; + } + + // Si la valeur a changé, mettre à jour + if (feature.last_value !== newValue) { + return true; + } + + // Si plus de 5 minutes se sont écoulées depuis la dernière mise à jour, mettre à jour + if (feature.last_value_changed && (now - feature.last_value_changed) > timeThreshold) { + return true; + } + + return false; +} + +module.exports = shouldUpdateFeature; \ No newline at end of file diff --git a/server/services/tessie/lib/utils/tessie.constants.js b/server/services/tessie/lib/utils/tessie.constants.js new file mode 100644 index 0000000000..92f8ce1faa --- /dev/null +++ b/server/services/tessie/lib/utils/tessie.constants.js @@ -0,0 +1,255 @@ +const GLADYS_VARIABLES = { + API_KEY: 'TESSIE_API_KEY', + WEBSOCKET_ENABLED: 'TESSIE_WEBSOCKET_ENABLED', + VEHICLES_API: 'TESSIE_VEHICLES_API', + DRIVERS_API: 'TESSIE_DRIVERS_API', + TELEMETRY_API: 'TESSIE_TELEMETRY_API', +}; + +const STATUS = { + NOT_INITIALIZED: 'not_initialized', + CONNECTING: 'connecting', + DISCONNECTING: 'disconnecting', + PROCESSING_TOKEN: 'processing token', + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + WEBSOCKET_CONNECTING: 'websocket connecting', + WEBSOCKET_CONNECTED: 'websocket connected', + WEBSOCKET_DISCONNECTED: 'websocket disconnected', + ERROR: { + CONNECTING: 'error connecting', + PROCESSING_TOKEN: 'error processing token', + DISCONNECTING: 'error disconnecting', + CONNECTED: 'error connected', + SET_VEHICLE_VALUES: 'error set vehicle values', + GET_VEHICLE_VALUES: 'error get vehicle values', + WEBSOCKET: 'error websocket', + }, + GET_VEHICLE_VALUES: 'get vehicle values', + DISCOVERING_VEHICLES: 'discovering', + VEHICLE_STATE: { + DRIVING: 'driving', + PARKING: 'parking', + CHARGING: 'charging', + }, +}; + +const GITHUB_BASE_URL = 'https://github.com/GladysAssistant/Gladys/issues/new'; +const BASE_API = 'https://api.tessie.com'; +const API = { + HEADER: { + ACCEPT: 'application/json', + CONTENT_TYPE: 'application/json', + }, + OAUTH2: `${BASE_API}/oauth2/authorize`, + TOKEN: `${BASE_API}/oauth2/token`, + VEHICLES: `${BASE_API}/vehicles?only_active=false`, + VEHICLE_STATE: `/state?use_cache=true`, + VEHICLE_COMMAND: `/command`, + VEHICLE_CHARGING: `/charges`, + // VEHICLE_LOCATION: `/location`, + VEHICLE_BATTERY: `/battery`, + // VEHICLE_BATTERY_HEALTH: `/battery/health`, + // VEHICLE_FIRMWARE: `/firmware/alerts`, + VEHICLE_CONSUMPTION: `/consumption_since_charge`, + // VEHICLE_WEATHER: `/weather`, + VEHICLE_DRIVES: `/drives`, + VEHICLE_CHARGES: `/charges`, + // VEHICLE_TIRES: `/tires`, + // VEHICLE_LICENSE: `/license`, + // VEHICLE_TELEMETRY: `/telemetry`, + // DRIVERS: `${BASE_API}/drivers`, + // DRIVER_INVITATIONS: `${BASE_API}/drivers/invitations`, +}; + +const SUPPORTED_CATEGORY_TYPE = { + VEHICLE: 'Vehicle', + CHARGING: 'Charging', + LOCATION: 'Location', + BATTERY: 'Battery', + DRIVER: 'Driver', + TELEMETRY: 'Telemetry', + UNKNOWN: 'unknown', +}; + +const SUPPORTED_MODULE_MODEL = { + TESLA_MODEL_S: 'ModelS', + TESLA_MODEL_3: 'Model3', + TESLA_MODEL_X: 'ModelX', + TESLA_MODEL_Y: 'ModelY', +}; + +const SUPPORTED_MODULE_TYPE = { + BASE: 'base', +}; + +const SUPPORTED_MODULE_VERSION = { + BASE: 'base', + LONG_RANGE: 'long-range', + AWD: 'awd', + PERFORMANCE: 'performance', + STANDARD: 'standard', +}; + +const EFFICIENCY_PACKAGE_YEAR = { + MY2021: '2021', + MY2022: '2022', + MY2023: '2023', + MY2024: '2024', +}; + +const BATTERY_CAPACITY = { + MODELS: { + STANDARD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + AWD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + LONG_RANGE: { + BATTERY_CAPACITY: 74, + BATTERY_RANGE: 300, + }, + PERFORMANCE: { + BATTERY_CAPACITY: 82, + BATTERY_RANGE: 300, + }, + }, + MODEL3: { + STANDARD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + AWD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + LONG_RANGE: { + BATTERY_CAPACITY: 74, + BATTERY_RANGE: 300, + }, + PERFORMANCE: { + BATTERY_CAPACITY: 82, + BATTERY_RANGE: 300, + }, + }, + HIGHLAND: { + STANDARD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + AWD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + LONG_RANGE: { + BATTERY_CAPACITY: 74, + BATTERY_RANGE: 300, + }, + PERFORMANCE: { + BATTERY_CAPACITY: 82, + BATTERY_RANGE: 300, + }, + }, + MODELY: { + STANDARD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + AWD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + LONG_RANGE: { + BATTERY_CAPACITY: 74, + BATTERY_RANGE: 523, + }, + PERFORMANCE: { + BATTERY_CAPACITY: 82, + BATTERY_RANGE: 300, + }, + }, + JUNIPER: { + STANDARD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + AWD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + LONG_RANGE: { + BATTERY_CAPACITY: 74, + BATTERY_RANGE: 300, + }, + PERFORMANCE: { + BATTERY_CAPACITY: 82, + BATTERY_RANGE: 300, + }, + }, + MODELX: { + STANDARD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + AWD: { + BATTERY_CAPACITY: 60, + BATTERY_RANGE: 250, + }, + LONG_RANGE: { + BATTERY_CAPACITY: 74, + BATTERY_RANGE: 300, + }, + PERFORMANCE: { + BATTERY_CAPACITY: 82, + BATTERY_RANGE: 300, + }, + }, +}; + +const PARAMS = { + VEHICLE_ID: 'vehicle_id', + VEHICLE_NAME: 'vehicle_name', + VEHICLE_VIN: 'vehicle_vin', + VEHICLE_STATE: 'vehicle_state', + VEHICLE_VERSION: 'vehicle_version', +}; + +const CAR_SPECIAL_TYPE = { + STANDARD: 'standard', + LONG_RANGE: 'long-range', + AWD: 'awd', + PERFORMANCE: 'performance', +}; + +const WEBSOCKET = { + BASE_URL: 'wss://streaming.tessie.com', + RECONNECT_INTERVAL: 5 * 1000, + MAX_RECONNECT_ATTEMPTS: 5, +}; + +const UPDATE_THRESHOLDS = { + DEFAULT: 5 * 60 * 1000, + BATTERY: 10 * 60 * 1000, + DRIVE: 10 * 60 * 1000, +}; + +module.exports = { + GLADYS_VARIABLES, + STATUS, + GITHUB_BASE_URL, + BASE_API, + API, + SUPPORTED_CATEGORY_TYPE, + SUPPORTED_MODULE_MODEL, + SUPPORTED_MODULE_TYPE, + SUPPORTED_MODULE_VERSION, + PARAMS, + EFFICIENCY_PACKAGE_YEAR, + BATTERY_CAPACITY, + CAR_SPECIAL_TYPE, + WEBSOCKET, + UPDATE_THRESHOLDS, +}; diff --git a/server/services/tessie/lib/websocket/disconnectAllWebSockets.js b/server/services/tessie/lib/websocket/disconnectAllWebSockets.js new file mode 100644 index 0000000000..7ea3a2a2d3 --- /dev/null +++ b/server/services/tessie/lib/websocket/disconnectAllWebSockets.js @@ -0,0 +1,20 @@ +const logger = require('../../../../utils/logger'); +const { STATUS } = require('../utils/tessie.constants'); + +async function disconnectAllWebSockets() { + logger.debug('Disconnecting all WebSocket connections...'); + + for (const [vin, ws] of this.websocketConnections) { + try { + ws.close(); + logger.debug(`WebSocket disconnected for VIN: ${vin}`); + } catch (error) { + logger.error(`Error disconnecting WebSocket for VIN ${vin}:`, error); + } + } + + this.websocketConnections.clear(); + await this.saveStatus({ statusType: STATUS.WEBSOCKET_DISCONNECTED, message: null }); +} + +module.exports = disconnectAllWebSockets; \ No newline at end of file diff --git a/server/services/tessie/lib/websocket/getWebSocketFeatureMapping.js b/server/services/tessie/lib/websocket/getWebSocketFeatureMapping.js new file mode 100644 index 0000000000..97734e825e --- /dev/null +++ b/server/services/tessie/lib/websocket/getWebSocketFeatureMapping.js @@ -0,0 +1,214 @@ +function getWebSocketFeatureMapping(key) { + const mappings = { + // Battery features + 'Soc': { + featureId: 'battery_level', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'BatteryLevel': { + featureId: 'battery_level', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'EstBatteryRange': { + featureId: 'battery_range_estimate', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 0 + }, + 'EnergyRemaining': { + featureId: 'battery_energy_remaining', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'PackVoltage': { + featureId: 'battery_voltage', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'PackCurrent': { + featureId: 'battery_current', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'battery_power': { + featureId: 'battery_power', + type: 'calculated_power', + decimalPlaces: 2 + }, // Calculé à partir de PackVoltage × PackCurrent + 'ModuleTempMin': { + featureId: 'battery_temperature_min', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'ModuleTempMax': { + featureId: 'battery_temperature_max', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'NumModuleTempMin': { + featureId: 'battery_temperature_min', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'NumModuleTempMax': { + featureId: 'battery_temperature_max', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + + // Climate features + 'HvacACEnabled': { + featureId: 'climate_on', + type: 'boolean', + valueProperty: 'boolValue' + }, + 'HvacPower': { + featureId: 'climate_on', + type: 'boolean', + valueProperty: 'hvacPowerValue', + enumMapping: { + 'HvacPowerStateOn': 1, + 'HvacPowerStateOff': 0 + } + }, + 'HvacFanStatus': { + featureId: 'climate_fan_status', + type: 'number', + valueProperty: 'intValue' + }, + 'InsideTemp': { + featureId: 'indoor_temperature', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'OutsideTemp': { + featureId: 'outside_temperature', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'HvacLeftTemperatureRequest': { + featureId: 'target_temperature', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + + // Charging features + 'ACChargingPower': { + featureId: 'charge_power', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'DCChargingPower': { + featureId: 'charge_power', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'ChargerVoltage': { + featureId: 'charge_voltage', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'ChargeAmps': { + featureId: 'charge_current', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + 'Location': { + featureId: 'location', + type: 'location', + valueProperty: 'locationValue' + }, + + // State features + 'door_value': { + featureId: 'door_state', + type: 'door_state', + valueProperty: 'doorValue' + }, // Door features - mapped individually from door_value + 'Odometer': { + featureId: 'odometer', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 0 + }, + 'TpmsPressureFl': { + featureId: 'tpms_pressure_fl', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'TpmsPressureFr': { + featureId: 'tpms_pressure_fr', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'TpmsPressureRl': { + featureId: 'tpms_pressure_rl', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'TpmsPressureRr': { + featureId: 'tpms_pressure_rr', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 1 + }, + 'FdWindow': { + featureId: 'window_fd_opened', + type: 'boolean', + valueProperty: 'boolValue' + }, + 'FpWindow': { + featureId: 'window_fp_opened', + type: 'boolean', + valueProperty: 'boolValue' + }, + 'RdWindow': { + featureId: 'window_rd_opened', + type: 'boolean', + valueProperty: 'boolValue' + }, + 'RpWindow': { + featureId: 'window_rp_opened', + type: 'boolean', + valueProperty: 'boolValue' + }, + 'VehicleSpeed': { + featureId: 'speed', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 0 + }, + 'Power': { + featureId: 'power', + type: 'number', + valueProperty: 'doubleValue', + decimalPlaces: 2 + }, + // 'Temperature': { featureId: 'temperature', type: 'number' }, + // 'ChargeState': { featureId: 'charge_state', type: 'string' }, + }; + return mappings[key] || null; +} + +module.exports = getWebSocketFeatureMapping; \ No newline at end of file diff --git a/server/services/tessie/lib/websocket/handleWebSocketMessage.js b/server/services/tessie/lib/websocket/handleWebSocketMessage.js new file mode 100644 index 0000000000..d9db1953a7 --- /dev/null +++ b/server/services/tessie/lib/websocket/handleWebSocketMessage.js @@ -0,0 +1,35 @@ +const logger = require('../../../../utils/logger'); + +async function handleWebSocketMessage(vin, message) { + logger.debug(`Received WebSocket message for VIN ${vin}:`, message); + logger.debug(`Received WebSocket message for VIN ${vin}:`, message.data); + + const externalId = `tessie:${vin}`; + const device = await this.gladys.stateManager.get('deviceByExternalId', externalId); + + if (!device) { + logger.warn(`Device not found for VIN ${vin}`); + return; + } + + try { + if (message.data) { + // Message de données de télémétrie + await this.updateValuesFromWebSocket(device, message.data, externalId, vin); + } else if (message.alerts) { + // Message d'alertes + // await this.handleAlerts(device, message.alerts, externalId, vin); + logger.warn(`Alerts message for VIN ${vin}:`, message.alerts); + } else if (message.connectionId) { + // Message de connectivité + logger.debug(`Connectivity message for VIN ${vin}: ${message.status}`); + } else if (message.errors) { + // Message d'erreurs + logger.error(`WebSocket errors for VIN ${vin}:`, message.errors); + } + } catch (error) { + logger.error(`Error handling WebSocket message for VIN ${vin}:`, error); + } +} + +module.exports = handleWebSocketMessage; \ No newline at end of file diff --git a/server/services/tessie/lib/websocket/initWebSocketConnections.js b/server/services/tessie/lib/websocket/initWebSocketConnections.js new file mode 100644 index 0000000000..65bc0a25f2 --- /dev/null +++ b/server/services/tessie/lib/websocket/initWebSocketConnections.js @@ -0,0 +1,20 @@ +const logger = require('../../../../utils/logger'); + +async function initWebSocketConnections() { + logger.debug('Initializing WebSocket connections for all vehicles...'); + + if (!this.configuration.websocketEnabled || !this.configuration.apiKey) { + logger.debug('WebSocket is disabled or API key not configured'); + return; + } + + for (const vehicle of this.vehicles) { + try { + await this.connectWebSocket(vehicle.vin, this.configuration.apiKey); + } catch (error) { + logger.error(`Failed to connect WebSocket for VIN ${vehicle.vin}:`, error); + } + } +} + +module.exports = initWebSocketConnections; \ No newline at end of file diff --git a/server/services/tessie/lib/websocket/parseWebSocketValue.js b/server/services/tessie/lib/websocket/parseWebSocketValue.js new file mode 100644 index 0000000000..7bd285caff --- /dev/null +++ b/server/services/tessie/lib/websocket/parseWebSocketValue.js @@ -0,0 +1,81 @@ +function parseWebSocketValue(value, mapping = null) { + // Validation de base + if (!value || typeof value !== 'object') { + return null; + } + + // Si on a un mapping spécifique, l'utiliser + if (mapping && mapping.valueProperty) { + const rawValue = value[mapping.valueProperty]; + + if (rawValue === undefined || rawValue === null) { + return null; + } + + // Gestion des énumérations (ex: HvacPower) + if (mapping.enumMapping && mapping.enumMapping[rawValue] !== undefined) { + return mapping.enumMapping[rawValue]; + } + + // Gestion des nombres avec formatage + if (mapping.decimalPlaces !== undefined && typeof rawValue === 'number') { + const formattedValue = Number(rawValue.toFixed(mapping.decimalPlaces)); + return isNaN(formattedValue) ? null : formattedValue; + } + + // Gestion des booléens + if (mapping.valueProperty === 'boolValue') { + return rawValue ? 1 : 0; + } + + // Gestion des entiers + if (mapping.valueProperty === 'intValue') { + const intValue = parseInt(rawValue); + return isNaN(intValue) ? null : intValue; + } + + // Valeur brute pour les autres types + if (typeof rawValue === 'number' && isNaN(rawValue)) { + return null; + } + return rawValue; + } + + // Fallback pour l'ancien système (rétrocompatibilité) + if (value.stringValue !== undefined) { + const num = parseFloat(value.stringValue); + return isNaN(num) ? value.stringValue : num; + } else if (value.locationValue) { + return { + latitude: value.locationValue.latitude, + longitude: value.locationValue.longitude + }; + } else if (value.doorValue !== undefined) { + // Parse door state according to Tessie documentation + // Doors message: bool DriverFront = 1; bool DriverRear = 2; bool PassengerFront = 3; bool PassengerRear = 4; bool TrunkFront = 5; bool TrunkRear = 6; + const doorValue = parseInt(value.doorValue); + if (isNaN(doorValue)) { + return null; + } + return { + driverFront: (doorValue & 1) !== 0, // bit 0 + driverRear: (doorValue & 2) !== 0, // bit 1 + passengerFront: (doorValue & 4) !== 0, // bit 2 + passengerRear: (doorValue & 8) !== 0, // bit 3 + trunkFront: (doorValue & 16) !== 0, // bit 4 + trunkRear: (doorValue & 32) !== 0 // bit 5 + }; + } else if (value.doubleValue !== undefined) { + const doubleValue = value.doubleValue; + return isNaN(doubleValue) ? null : doubleValue; + } else if (value.intValue !== undefined) { + const intValue = value.intValue; + return isNaN(intValue) ? null : intValue; + } else if (value.boolValue !== undefined) { + return value.boolValue ? 1 : 0; + } + + return null; +} + +module.exports = parseWebSocketValue; \ No newline at end of file diff --git a/server/services/tessie/lib/websocket/reconnectWebSocket.js b/server/services/tessie/lib/websocket/reconnectWebSocket.js new file mode 100644 index 0000000000..1150bc9aaf --- /dev/null +++ b/server/services/tessie/lib/websocket/reconnectWebSocket.js @@ -0,0 +1,12 @@ +const logger = require('../../../../utils/logger'); + +async function reconnectWebSocket(vin, apiKey) { + logger.debug(`Attempting to reconnect WebSocket for VIN: ${vin}`); + try { + await this.connectWebSocket(vin, apiKey); + } catch (error) { + logger.error(`Failed to reconnect WebSocket for VIN ${vin}:`, error); + } +} + +module.exports = reconnectWebSocket; \ No newline at end of file diff --git a/server/services/tessie/lib/websocket/updateValuesFromWebSocket.js b/server/services/tessie/lib/websocket/updateValuesFromWebSocket.js new file mode 100644 index 0000000000..92f2960644 --- /dev/null +++ b/server/services/tessie/lib/websocket/updateValuesFromWebSocket.js @@ -0,0 +1,167 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); + +// Cache temporaire pour stocker les valeurs de tension et courant +const batteryCache = new Map(); +const CACHE_TTL = 30 * 1000; // 5 secondes de TTL + +async function updateValuesFromWebSocket(device, data, externalId, vin) { + // Nettoyer le cache expiré + const now = Date.now(); + if (batteryCache.has(vin)) { + const cacheEntry = batteryCache.get(vin); + if (now - cacheEntry.timestamp > CACHE_TTL) { + batteryCache.delete(vin); + } + } + + // Initialiser ou récupérer le cache pour ce VIN + if (!batteryCache.has(vin)) { + batteryCache.set(vin, { + packVoltage: null, + packCurrent: null, + timestamp: now + }); + } + const cache = batteryCache.get(vin); + + // First pass: collect PackVoltage and PackCurrent to calculate battery_power + for (const item of data) { + const { key, value } = item; + const mapping = this.getWebSocketFeatureMapping(key); + if (key === 'ChargeState') { + console.log('ChargeState', value); + console.log('data', data); + console.log('mapping', mapping); + } + if (key === 'PackVoltage') { + const parsedValue = this.parseWebSocketValue(value, mapping); + if (parsedValue !== null && !isNaN(parsedValue)) { + cache.packVoltage = parsedValue; + cache.timestamp = now; // Mettre à jour le timestamp + } + } else if (key === 'PackCurrent') { + const parsedValue = this.parseWebSocketValue(value, mapping); + if (parsedValue !== null && !isNaN(parsedValue)) { + cache.packCurrent = parsedValue; + cache.timestamp = now; // Mettre à jour le timestamp + } + } + } + + // Second pass: process all data + for (const item of data) { + const { key, value } = item; + + // Map WebSocket keys to Gladys features + const featureMapping = this.getWebSocketFeatureMapping(key); + if (featureMapping) { + let newValue = this.parseWebSocketValue(value, featureMapping); + + // Validation of values + if (newValue !== null && newValue !== undefined && !isNaN(newValue)) { + // Special case for doors that require updating multiple features + if (featureMapping.type === 'door_state' && typeof newValue === 'object') { + // Update each door individually + const doorFeatures = { + 'door_df_opened': newValue.driverFront ? 1 : 0, + 'door_dr_opened': newValue.driverRear ? 1 : 0, + 'door_pf_opened': newValue.passengerFront ? 1 : 0, + 'door_pr_opened': newValue.passengerRear ? 1 : 0, + 'trunk_front_opened': newValue.trunkFront ? 1 : 0, + 'trunk_rear_opened': newValue.trunkRear ? 1 : 0 + }; + + for (const [doorFeatureId, doorValue] of Object.entries(doorFeatures)) { + const doorFeature = device.features.find(f => f.external_id === `${externalId}:${doorFeatureId}`); + if (doorFeature) { + // Check if the value has changed or if more than 5 minutes have elapsed + const shouldUpdate = this.shouldUpdateFeature(doorFeature, doorValue); + if (shouldUpdate) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: doorFeature.external_id, + state: doorValue + }); + logger.debug(`Updated door ${doorFeatureId}: ${doorValue} for VIN ${vin}`); + } + } + } + } else { + // Normal case for other features + const feature = device.features.find(f => f.external_id === `${externalId}:${featureMapping.featureId}`); + if (feature) { + // Check if the value has changed or if more than 5 minutes have elapsed + const shouldUpdate = this.shouldUpdateFeature(feature, newValue); + if (shouldUpdate) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: newValue + }); + logger.debug(`Updated ${key} (${featureMapping.valueProperty || 'unknown'}): ${newValue} for VIN ${vin}`); + } else { + logger.debug(`Skipped ${key}: value unchanged (${newValue}) for VIN ${vin}`); + } + } + } + } else { + logger.debug(`Skipping ${key} - invalid value: ${newValue} for VIN ${vin}`); + } + } + } + + // Calculate and update battery_power if PackVoltage and PackCurrent are available in cache + if (cache.packVoltage !== null && cache.packCurrent !== null && + !isNaN(cache.packVoltage) && !isNaN(cache.packCurrent)) { + + // Calculation of power: P = V × I + const batteryPower = cache.packVoltage * cache.packCurrent; // Power in watts + + // Validation of the calculation result + const featureMapping = this.getWebSocketFeatureMapping('battery_power'); + if (featureMapping) { + let newValue = this.parseWebSocketValue(batteryPower, featureMapping); + if (newValue !== null && newValue !== undefined && !isNaN(newValue)) { + const feature = device.features.find(f => f.external_id === `${externalId}:${featureMapping.featureId}`); + if (feature) { + const shouldUpdate = this.shouldUpdateFeature(feature, newValue); + if (shouldUpdate) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: newValue + }); + logger.debug(`Updated battery_power: ${newValue}W for VIN ${vin}`); + } + } else { + logger.debug(`Skipped battery_power: value unchanged (${newValue}) for VIN ${vin}`); + logger.warn(`Invalid battery_power calculation: ${cache.packVoltage}V × ${cache.packCurrent}A = ${batteryPower} for VIN ${vin}`); + } + } + } + // if (!isNaN(batteryPower) && isFinite(batteryPower)) { + + // const batteryPowerFeature = device.features.find(f => f.external_id === `${externalId}:battery_power`); + // if (batteryPowerFeature) { + // // Check if the value has changed or if more than 5 minutes have elapsed + // const shouldUpdate = this.shouldUpdateFeature(batteryPowerFeature, batteryPower); + // if (shouldUpdate) { + // this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + // device_feature_external_id: batteryPowerFeature.external_id, + // state: batteryPower + // }); + // logger.debug(`Calculated battery_power: ${batteryPower}W (${cache.packVoltage}V × ${cache.packCurrent}A) for VIN ${vin}`); + // } else { + // logger.debug(`Skipped battery_power: value unchanged (${batteryPower}W) for VIN ${vin}`); + // } + // } + // } else { + // logger.warn(`Invalid battery_power calculation: ${cache.packVoltage}V × ${cache.packCurrent}A = ${batteryPower} for VIN ${vin}`); + // } + } else { + // Log debug info when values are missing + if (cache.packVoltage === null || cache.packCurrent === null) { + logger.debug(`Waiting for battery values - PackVoltage: ${cache.packVoltage}, PackCurrent: ${cache.packCurrent} for VIN ${vin}`); + } + } +} + +module.exports = updateValuesFromWebSocket; \ No newline at end of file diff --git a/server/services/tessie/package-lock.json b/server/services/tessie/package-lock.json new file mode 100644 index 0000000000..bd823104b8 --- /dev/null +++ b/server/services/tessie/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "gladys-tessie", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-tessie", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "bluebird": "^3.7.2", + "undici": "^7.5.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/undici": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.5.0.tgz", + "integrity": "sha512-NFQG741e8mJ0fLQk90xKxFdaSM7z4+IQpAgsFI36bCDY9Z2+aXXZjVy2uUksMouWfMI9+w5ejOq5zYYTBCQJDQ==", + "engines": { + "node": ">=20.18.1" + } + } + } +} diff --git a/server/services/tessie/package.json b/server/services/tessie/package.json new file mode 100644 index 0000000000..8db1fecd6a --- /dev/null +++ b/server/services/tessie/package.json @@ -0,0 +1,19 @@ +{ + "name": "gladys-tessie", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": { + "bluebird": "^3.7.2", + "undici": "^7.5.0" + } +} diff --git a/server/test/services/tessie/.eslintrc.json b/server/test/services/tessie/.eslintrc.json new file mode 100644 index 0000000000..598403730f --- /dev/null +++ b/server/test/services/tessie/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "rules": { + "no-underscore-dangle": "off", + "no-promise-executor-return": "off" + } +} diff --git a/server/test/services/tessie/controllers/netatmo.controller.test.js b/server/test/services/tessie/controllers/netatmo.controller.test.js new file mode 100644 index 0000000000..08403fe3a7 --- /dev/null +++ b/server/test/services/tessie/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/configuration'].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/tessie/index.test.js b/server/test/services/tessie/index.test.js new file mode 100644 index 0000000000..19964cf0eb --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/device/netatmo.convertDeviceEnergy.test.js b/server/test/services/tessie/lib/device/netatmo.convertDeviceEnergy.test.js new file mode 100644 index 0000000000..a816e924ad --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.convertDeviceEnergy.test.js @@ -0,0 +1,263 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { SUPPORTED_MODULE_TYPE } = require('../../../../../services/netatmo/lib/utils/netatmo.constants'); +const devicesNetatmoMock = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const devicesGladysMock = JSON.parse(JSON.stringify(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 Energy 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 deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAPlug')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.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:${deviceNetatmoMock.id}:plug_connected_boiler`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).to.have.property('value', JSON.stringify(deviceNetatmoMock.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 deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NATherm1')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.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:${deviceNetatmoMock.id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Thermostat device without room and without plug', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NATherm1')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NATherm1')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:04:00:00:xx:xx:xx:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params = [ + { + name: 'home_id', + value: '5e1xxxxxxxxxxxxxxxxx', + }, + ]; + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.plug = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.THERMOSTAT); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock[0]).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:temperature`); + expect(featureMock.length).to.deep.equal(1); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.deep.equal(undefined); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Valve device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NRV')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NRV')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NRV); + + const featureMock = gladysDevice.features.filter((feature) => feature.type === 'target-temperature')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Valve device with secondaries names and ids', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NRV')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NRV')[0] }; + deviceNetatmoMock._id = deviceNetatmoMock.id; + deviceNetatmoMock.id = undefined; + deviceNetatmoMock.home_id = deviceNetatmoMock.home; + deviceNetatmoMock.home = undefined; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + deviceNetatmoMock.plug._id = deviceNetatmoMock.plug.id; + deviceNetatmoMock.plug.id = undefined; + deviceNetatmoMock.plug.module_name = deviceNetatmoMock.plug.name; + deviceNetatmoMock.plug.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock._id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NRV); + + const featureMock = gladysDevice.features.filter((feature) => feature.type === 'target-temperature')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock._id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.module_name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Valve device without room and without plug', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NRV')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NRV')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:09:00:00:xx:xx:xx:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params = [ + { + name: 'home_id', + value: '5e1xxxxxxxxxxxxxxxxx', + }, + ]; + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.plug = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NRV); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock.length).to.deep.equal(0); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.deep.equal(undefined); + + 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 deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAPlug')[1] }; + deviceNetatmoMock.modules_bridged = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.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:${deviceNetatmoMock.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: '[]' }); + + const paramRoomMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramRoomMock).to.equal(undefined); + }); + + it('should correctly convert a Netatmo device not supported', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + 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:${deviceNetatmoMock.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', deviceNetatmoMock.room.name); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js b/server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js new file mode 100644 index 0000000000..75375be421 --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js @@ -0,0 +1,93 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const devicesNetatmoMock = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const devicesGladysMock = JSON.parse(JSON.stringify(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 not supported', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should correctly convert a Netatmo device not supported', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceNotSupported(deviceNetatmoMock); + + 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:${deviceNetatmoMock.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', deviceNetatmoMock.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 with secondary datas', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NOC')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceNetatmoMock._id = deviceNetatmoMock.id; + deviceNetatmoMock.id = undefined; + deviceNetatmoMock.home_id = deviceNetatmoMock.home; + deviceNetatmoMock.home = undefined; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + deviceNetatmoMock.room = undefined; + + const gladysDevice = netatmoHandler.convertDeviceNotSupported(deviceNetatmoMock); + + 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:${deviceNetatmoMock._id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramMock).to.equal(undefined); + }); + + it('should correctly convert a Netatmo device not supported with station_name', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + deviceNetatmoMock.station_name = deviceNetatmoMock.name; + deviceNetatmoMock.module_name = undefined; + deviceNetatmoMock.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceNotSupported(deviceNetatmoMock); + + 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:${deviceNetatmoMock.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', deviceNetatmoMock.room.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js b/server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js new file mode 100644 index 0000000000..5ba4c21b57 --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js @@ -0,0 +1,262 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { SUPPORTED_MODULE_TYPE } = require('../../../../../services/netatmo/lib/utils/netatmo.constants'); +const devicesNetatmoMock = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const devicesGladysMock = JSON.parse(JSON.stringify(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 Weather Device', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should correctly convert a Netatmo Weather Station NAMain device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAMain')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAMain')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMAIN); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor')[2]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:min_temp`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).to.have.property('value', JSON.stringify(deviceNetatmoMock.modules_bridged)); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Outdoor module NAModule1 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule1')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule1')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE1); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'humidity-sensor')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:humidity`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Anemometer NAModule2 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule2')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule2')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE2); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'speed-sensor')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:wind_strength`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Rain gauge Weather Station NAModule3 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule3')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule3')[0] }; + deviceNetatmoMock.station_name = deviceNetatmoMock.name; + deviceNetatmoMock.module_name = undefined; + deviceNetatmoMock.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE3); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'precipitation-sensor')[2]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:sum_rain_24`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Indoor module Weather Station NAModule4 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule4')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule4')[0] }; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + deviceNetatmoMock.plug._id = deviceNetatmoMock.plug.id; + deviceNetatmoMock.plug.id = undefined; + deviceNetatmoMock.plug.module_name = deviceNetatmoMock.plug.name; + deviceNetatmoMock.plug.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE4); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock[2]).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:min_temp`); + expect(featureMock.length).to.deep.equal(4); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.module_name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Weather Station device without modules_bridged and without room', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAMain')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAMain')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:70:ee:50:jj:jj:jj:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params + .filter((param) => param.name === 'modules_bridge_id') + .forEach((param) => { + param.value = '[]'; + }); + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.modules_bridged = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMAIN); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).deep.equal({ name: 'modules_bridge_id', value: '[]' }); + + const paramRoomMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramRoomMock).to.equal(undefined); + }); + + it('should correctly convert a Netatmo Weather Station NAModule4 device without room and without plug', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule4')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule4')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:03:00:00:yy:yy:yy:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params = [ + { + name: 'home_id', + value: '5e1xxxxxxxxxxxxxxxxx', + }, + ]; + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.plug = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE4); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock[1]).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:min_temp`); + expect(featureMock.length).to.deep.equal(3); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.deep.equal(undefined); + + 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 deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + deviceNetatmoMock._id = deviceNetatmoMock.id; + deviceNetatmoMock.id = undefined; + deviceNetatmoMock.home_id = deviceNetatmoMock.home; + deviceNetatmoMock.home = undefined; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + 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:${deviceNetatmoMock._id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + expect(gladysDevice).to.have.property('not_handled', true); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js b/server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js new file mode 100644 index 0000000000..71186f48c5 --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js @@ -0,0 +1,104 @@ +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); + }); + + it('should correctly transform SPEED_SENSOR.INTEGER value from Netatmo to Gladys', () => { + const valueFromDevice = 10; + const valueFromDeviceFloat = 10.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.SPEED_SENSOR][DEVICE_FEATURE_TYPES.SPEED_SENSOR.INTEGER]; + + expect(mappingFunction(valueFromDevice)).to.equal(10); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(10); + }); + + it('should correctly transform ANGLE_SENSOR.INTEGER value from Netatmo to Gladys', () => { + const valueFromDevice = 10; + const valueFromDeviceFloat = 10.5; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.ANGLE_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.INTEGER]; + + expect(mappingFunction(valueFromDevice)).to.equal(10); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(10); + }); + + it('should correctly transform PRECIPITATION_SENSOR.DECIMAL value from Netatmo to Gladys', () => { + const valueFromDevice = 1; + const valueFromDeviceFloat = 1.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.PRECIPITATION_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]; + + expect(mappingFunction(valueFromDevice)).to.equal(1); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(1.5); + }); + }); +}); diff --git a/server/test/services/tessie/lib/index.test.js b/server/test/services/tessie/lib/index.test.js new file mode 100644 index 0000000000..28b8b4755c --- /dev/null +++ b/server/test/services/tessie/lib/index.test.js @@ -0,0 +1,25 @@ +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, + energyApi: null, + weatherApi: 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/tessie/lib/netatmo.connect.test.js b/server/test/services/tessie/lib/netatmo.connect.test.js new file mode 100644 index 0000000000..9f54d1297c --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.disconnect.test.js b/server/test/services/tessie/lib/netatmo.disconnect.test.js new file mode 100644 index 0000000000..47bb468e1b --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.discoverDevices.test.js b/server/test/services/tessie/lib/netatmo.discoverDevices.test.js new file mode 100644 index 0000000000..8d8451094e --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.getAccessToken.test.js b/server/test/services/tessie/lib/netatmo.getAccessToken.test.js new file mode 100644 index 0000000000..b35b7af667 --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.getConfiguration.test.js b/server/test/services/tessie/lib/netatmo.getConfiguration.test.js new file mode 100644 index 0000000000..5e29b3988f --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.getConfiguration.test.js @@ -0,0 +1,73 @@ +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'); + } + if (variableName === 'NETATMO_ENERGY_API') { + return Promise.resolve('1'); + } + if (variableName === 'NETATMO_WEATHER_API') { + return Promise.resolve('0'); + } + 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'); + expect(configuration.energyApi).to.equal(true); + expect(configuration.weatherApi).to.equal(false); + }); + + 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/tessie/lib/netatmo.getRefreshToken.test.js b/server/test/services/tessie/lib/netatmo.getRefreshToken.test.js new file mode 100644 index 0000000000..bfc93fb442 --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.getStatus.test.js b/server/test/services/tessie/lib/netatmo.getStatus.test.js new file mode 100644 index 0000000000..abc9d24827 --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.init.test.js b/server/test/services/tessie/lib/netatmo.init.test.js new file mode 100644 index 0000000000..4c03e633b6 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.init.test.js @@ -0,0 +1,98 @@ +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_ENERGY_API') { + return Promise.resolve('1'); + } + if (variableName === 'NETATMO_WEATHER_API') { + return Promise.resolve('0'); + } + 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.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = false; + 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/tessie/lib/netatmo.loadDeviceDetails.test.js b/server/test/services/tessie/lib/netatmo.loadDeviceDetails.test.js new file mode 100644 index 0000000000..5d5341a1da --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadDeviceDetails.test.js @@ -0,0 +1,321 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const bodyHomesDataMock = JSON.parse(JSON.stringify(require('../netatmo.homesdata.mock.test.json'))); +const bodyHomeStatusMock = JSON.parse(JSON.stringify(require('../netatmo.homestatus.mock.test.json'))); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; +const homesMock = bodyHomesDataMock.homes[0]; + +describe('Netatmo Load Device Details', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load device details successfully with API not configured', async () => { + netatmoHandler.configuration.energyApi = false; + netatmoHandler.configuration.weatherApi = false; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'ok', + }); + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.have.lengthOf(10); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + const natValveDevices = devices.filter((device) => device.type === 'NRV'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + const natNAModule2Devices = devices.filter((device) => device.type === 'NAModule2'); + const natNAModule3Devices = devices.filter((device) => device.type === 'NAModule3'); + const natNAModule4Devices = devices.filter((device) => device.type === 'NAModule4'); + const natNotHandledDevices = devices.filter((device) => device.not_handled); + + expect(natThermDevices).to.have.lengthOf(1); + expect(natPlugDevices).to.have.lengthOf(2); + expect(natValveDevices).to.have.lengthOf(1); + expect(natWeatherStationDevices).to.have.lengthOf(1); + expect(natNAModule1Devices).to.have.lengthOf(1); + expect(natNAModule2Devices).to.have.lengthOf(1); + expect(natNAModule3Devices).to.have.lengthOf(1); + expect(natNAModule4Devices).to.have.lengthOf(1); + expect(natNotHandledDevices).to.have.lengthOf(1); + natThermDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(true); + 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'); + }); + natWeatherStationDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(true); + expect(device.categoryAPI).to.be.eq('Weather'); + expect(device) + .to.have.property('modules_bridged') + .that.is.an('array'); + }); + }); + + it('should no load device details without modules with API configured', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = true; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'ok', + }); + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.have.lengthOf(10); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + natThermDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(false); + }); + natWeatherStationDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(false); + }); + }); + + 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', + ); + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMockFake.id}`, + }) + .reply(200, { + body: bodyHomeStatusMockFake, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.have.lengthOf(9); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + const natValveDevices = devices.filter((device) => device.type === 'NRV'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + const natNAModule2Devices = devices.filter((device) => device.type === 'NAModule2'); + const natNAModule3Devices = devices.filter((device) => device.type === 'NAModule3'); + const natNAModule4Devices = devices.filter((device) => device.type === 'NAModule4'); + const natNotHandledDevices = devices.filter((device) => device.not_handled); + expect(natThermDevices).to.have.lengthOf(0); + expect(natPlugDevices).to.have.lengthOf(2); + expect(natValveDevices).to.have.lengthOf(1); + expect(natWeatherStationDevices).to.have.lengthOf(1); + expect(natNAModule1Devices).to.have.lengthOf(1); + expect(natNAModule2Devices).to.have.lengthOf(1); + expect(natNAModule3Devices).to.have.lengthOf(1); + expect(natNAModule4Devices).to.have.lengthOf(1); + expect(natNotHandledDevices).to.have.lengthOf(1); + expect(devices).to.be.an('array'); + }); + + it('should load device details successfully without weatherStation', async () => { + const homesMockFake = { ...JSON.parse(JSON.stringify(homesMock)) }; + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NAMain'); + const bodyHomeStatusMockFake = { ...JSON.parse(JSON.stringify(bodyHomeStatusMock)) }; + bodyHomeStatusMockFake.home.modules = bodyHomeStatusMock.home.modules.filter((module) => module.type !== 'NAMain'); + netatmoHandler.loadWeatherStationDetails = sinon.stub().resolves([]); + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMockFake.id}`, + }) + .reply(200, { + body: bodyHomeStatusMockFake, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.have.lengthOf(9); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + const natValveDevices = devices.filter((device) => device.type === 'NRV'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + const natNAModule2Devices = devices.filter((device) => device.type === 'NAModule2'); + const natNAModule3Devices = devices.filter((device) => device.type === 'NAModule3'); + const natNAModule4Devices = devices.filter((device) => device.type === 'NAModule4'); + const natNotHandledDevices = devices.filter((device) => device.not_handled); + expect(natThermDevices).to.have.lengthOf(1); + expect(natPlugDevices).to.have.lengthOf(2); + expect(natValveDevices).to.have.lengthOf(1); + expect(natWeatherStationDevices).to.have.lengthOf(0); + expect(natNAModule1Devices).to.have.lengthOf(1); + expect(natNAModule2Devices).to.have.lengthOf(1); + expect(natNAModule3Devices).to.have.lengthOf(1); + expect(natNAModule4Devices).to.have.lengthOf(1); + expect(natNotHandledDevices).to.have.lengthOf(1); + }); + + it('should load device details successfully but without weather station details', async () => { + netatmoHandler.loadWeatherStationDetails = sinon.stub().resolves([]); + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + expect(natWeatherStationDevices).to.have.lengthOf.at.least(1); + expect(natNAModule1Devices).to.have.lengthOf.at.least(1); + natWeatherStationDevices.forEach((device) => { + expect(device) + .to.have.property('room') + .that.is.an('object'); + expect(device) + .to.have.property('modules_bridged') + .that.is.an('array'); + expect(device).to.not.have.property('dashboard_data'); + }); + expect(natNAModule1Devices).to.have.lengthOf.at.least(1); + natNAModule1Devices.forEach((device) => { + expect(device) + .to.have.property('plug') + .that.is.an('object'); + expect(device.plug).to.not.have.property('dashboard_data'); + }); + }); + + 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'); + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NAMain'); + const bodyHomeStatusMockFake = { ...bodyHomeStatusMock }; + bodyHomeStatusMockFake.home.modules = undefined; + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMockFake.id}`, + }) + .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 () => { + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(400, { + status: 'error', + 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 () => { + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .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/tessie/lib/netatmo.loadDevices.test.js b/server/test/services/tessie/lib/netatmo.loadDevices.test.js new file mode 100644 index 0000000000..7841397109 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadDevices.test.js @@ -0,0 +1,309 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const devicesMock = JSON.parse(JSON.stringify(require('../netatmo.loadDevicesComplete.mock.test.json'))); +const deviceDetailsMock = JSON.parse(JSON.stringify(require('../netatmo.loadDevicesDetails.mock.test.json'))); +const thermostatsDetailsMock = JSON.parse(JSON.stringify(require('../netatmo.loadThermostatDetails.mock.test.json'))); +const weatherStationsDetailsMock = JSON.parse( + JSON.stringify(require('../netatmo.loadWeatherStationDetails.mock.test.json')), +); +const bodyHomesDataMock = JSON.parse(JSON.stringify(require('../netatmo.homesdata.mock.test.json'))); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); +const logger = require('../../../../utils/logger'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Devices', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.configuration.energyApi = false; + netatmoHandler.configuration.weatherApi = false; + netatmoHandler.accessToken = accessToken; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves(deviceDetailsMock); + netatmoHandler.loadThermostatDetails = sinon.stub().resolves(thermostatsDetailsMock); + netatmoHandler.loadWeatherStationDetails = sinon.stub().resolves(weatherStationsDetailsMock); + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load all devices successfully if all API not configured', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.not.deep.eq([]); + }); + + it('should load energy devices successfully', async () => { + netatmoHandler.configuration.energyApi = true; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.not.deep.eq([]); + }); + + it('should load thermostat devices successfully if no devices in loadDeviceDetails', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves([]); + const plugsMock = [...JSON.parse(JSON.stringify(thermostatsDetailsMock.plugs))]; + const thermostatsMock = [...JSON.parse(JSON.stringify(thermostatsDetailsMock.thermostats))]; + + thermostatsMock.forEach((thermostat) => { + plugsMock.push(thermostat); + }); + plugsMock + .filter((device) => device.type === 'NAPlug') + .forEach((plug) => { + if (!plug.modules_bridged) { + plug.modules_bridged = plug.modules.map((module) => module._id); + } + }); + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + expect(devices).to.be.an('array'); + expect(devices).to.deep.eq(plugsMock); + }); + + it('should load weather devices successfully if no devices in loadDeviceDetails', async () => { + netatmoHandler.configuration.weatherApi = true; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves([]); + const weatherStationsMock = [...JSON.parse(JSON.stringify(weatherStationsDetailsMock.weatherStations))]; + const modulesWeatherStationsMock = [ + ...JSON.parse(JSON.stringify(weatherStationsDetailsMock.modulesWeatherStations)), + ]; + modulesWeatherStationsMock.forEach((moduleWeatherStation) => { + weatherStationsMock.push(moduleWeatherStation); + }); + weatherStationsMock + .filter((device) => device.type === 'NAMain') + .forEach((plug) => { + if (!plug.modules_bridged) { + plug.modules_bridged = plug.modules.map((module) => module._id); + } + }); + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.deep.eq(weatherStationsMock); + }); + + it('should load energy and weather devices successfully', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = true; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.deep.eq(devicesMock); + }); + + it('should handle API errors gracefully', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should handle unexpected API responses', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/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 () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + const badBodyHomesData = { ...JSON.parse(JSON.stringify(bodyHomesDataMock)) }; + badBodyHomesData.homes[0].modules = undefined; + badBodyHomesData.homes[1].modules = undefined; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/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 () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + const bodyHomesDataEmpty = { ...JSON.parse(JSON.stringify(bodyHomesDataMock)) }; + bodyHomesDataEmpty.homes = []; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/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 () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + const bodyHomesDataNoModules = { ...JSON.parse(JSON.stringify(bodyHomesDataMock)) }; + bodyHomesDataNoModules.homes.forEach((home) => { + home.modules = undefined; + }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataNoModules, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should handle API errors on loadThermostatDetails and loadWeatherStationDetails', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().rejects(new Error('Failed to load thermostatsDetails')); + netatmoHandler.loadWeatherStationDetails = sinon.stub().rejects(new Error('Failed to load weatherStationsDetails')); + sinon.stub(logger, 'error'); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.not.deep.eq([]); + sinon.assert.calledTwice(logger.error); + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js b/server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js new file mode 100644 index 0000000000..d76ce48644 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js @@ -0,0 +1,143 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const bodyGetThermostatMock = JSON.parse(JSON.stringify(require('../netatmo.getThermostat.mock.test.json'))); +const thermostatsDetailsMock = JSON.parse(JSON.stringify(require('../netatmo.loadThermostatDetails.mock.test.json'))); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Thermostat Details', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load thermostat details successfully with API not configured', async () => { + netatmoHandler.configuration.energyApi = false; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(200, { + body: bodyGetThermostatMock, + status: 'ok', + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + + expect(plugs).to.deep.eq(thermostatsDetailsMock.plugs); + expect(thermostats).to.deep.eq(thermostatsDetailsMock.thermostats); + expect(plugs).to.be.an('array'); + expect(thermostats).to.be.an('array'); + }); + + it('should load thermostat details successfully with API configured', async () => { + netatmoHandler.configuration.energyApi = true; + thermostatsDetailsMock.plugs.forEach((plug) => { + plug.apiNotConfigured = false; + plug.modules.forEach((module) => { + module.apiNotConfigured = false; + module.plug.apiNotConfigured = false; + }); + }); + thermostatsDetailsMock.thermostats.forEach((thermostat) => { + thermostat.apiNotConfigured = false; + thermostat.plug.apiNotConfigured = false; + }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(200, { + body: bodyGetThermostatMock, + status: 'ok', + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + expect(plugs).to.deep.eq(thermostatsDetailsMock.plugs); + expect(thermostats).to.deep.eq(thermostatsDetailsMock.thermostats); + expect(plugs).to.be.an('array'); + expect(thermostats).to.be.an('array'); + }); + + it('should handle API errors gracefully', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + + expect(plugs).to.be.eq(undefined); + expect(thermostats).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(200, { + body: bodyGetThermostatMock, + status: 'error', + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + expect(plugs).to.deep.eq(bodyGetThermostatMock.devices); + expect(thermostats).to.deep.eq([]); + expect(plugs).to.be.an('array'); + expect(thermostats).to.be.an('array'); + expect(thermostats).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js b/server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js new file mode 100644 index 0000000000..45d689a165 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js @@ -0,0 +1,144 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const bodyGetWeatherStationMock = JSON.parse(JSON.stringify(require('../netatmo.getWeatherStation.mock.test.json'))); +const weatherStationsDetailsMock = JSON.parse( + JSON.stringify(require('../netatmo.loadWeatherStationDetails.mock.test.json')), +); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Weather Station Details', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load weather station details successfully with API not configured', async () => { + netatmoHandler.configuration.weatherApi = false; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(200, { + body: bodyGetWeatherStationMock, + status: 'ok', + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + expect(weatherStations).to.deep.eq(weatherStationsDetailsMock.weatherStations); + expect(modulesWeatherStations).to.deep.eq(weatherStationsDetailsMock.modulesWeatherStations); + expect(weatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.be.an('array'); + }); + + it('should load weather station details successfully with API configured', async () => { + netatmoHandler.configuration.weatherApi = true; + weatherStationsDetailsMock.weatherStations.forEach((weatherStation) => { + weatherStation.apiNotConfigured = false; + weatherStation.modules.forEach((module) => { + module.apiNotConfigured = false; + module.plug.apiNotConfigured = false; + }); + }); + weatherStationsDetailsMock.modulesWeatherStations.forEach((moduleWeatherStations) => { + moduleWeatherStations.apiNotConfigured = false; + moduleWeatherStations.plug.apiNotConfigured = false; + }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(200, { + body: bodyGetWeatherStationMock, + status: 'ok', + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + expect(weatherStations).to.deep.eq(weatherStationsDetailsMock.weatherStations); + expect(modulesWeatherStations).to.deep.eq(weatherStationsDetailsMock.modulesWeatherStations); + expect(weatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.be.an('array'); + }); + + it('should handle API errors gracefully', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + + expect(weatherStations).to.be.eq(undefined); + expect(modulesWeatherStations).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(200, { + body: bodyGetWeatherStationMock, + status: 'error', + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + expect(weatherStations).to.deep.eq(bodyGetWeatherStationMock.devices); + expect(modulesWeatherStations).to.deep.eq([]); + expect(weatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js b/server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js new file mode 100644 index 0000000000..ab24b9f050 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js @@ -0,0 +1,155 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +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; + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + 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(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + 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'; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(200, tokens); + + clock.tick(3600 * 1000); + clock.restore(); + 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/tessie/lib/netatmo.pollRefreshingValues.test.js b/server/test/services/tessie/lib/netatmo.pollRefreshingValues.test.js new file mode 100644 index 0000000000..53f9af5891 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.pollRefreshingValues.test.js @@ -0,0 +1,129 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesMock = JSON.parse(JSON.stringify(require('../netatmo.loadDevices.mock.test.json'))); +const devicesExistsMock = JSON.parse(JSON.stringify(require('../netatmo.discoverDevices.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), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; + +describe('Netatmo pollRefreshingValues', () => { + let clock; + let netatmoHandler; + + beforeEach(() => { + sinon.reset(); + + clock = sinon.useFakeTimers(); + netatmoHandler = new NetatmoHandler(gladys, serviceId); + netatmoHandler.status = 'not_initialized'; + netatmoHandler.configured = true; + netatmoHandler.connected = true; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should refresh device values periodically', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.refreshNetatmoValues = sinon.stub().resolves(); + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(devicesExistsMock); + + netatmoHandler.pollRefreshingValues(); + + clock.tick(120 * 1000); + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.calledOnce(netatmoHandler.refreshNetatmoValues); + }); + + it('should handle an error during device loading', async () => { + netatmoHandler.refreshNetatmoValues = sinon.stub().rejects(new Error('Failed to load devices')); + sinon.stub(logger, 'error'); + + netatmoHandler.pollRefreshingValues(); + + clock.tick(120 * 1000); + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.called(netatmoHandler.refreshNetatmoValues); + sinon.assert.calledOnce(logger.error); + + logger.error.restore(); + }); + + it('should refresh device values', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + netatmoHandler.updateValues = sinon.stub().resolves(); + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(devicesExistsMock); + + netatmoHandler.refreshNetatmoValues(); + + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.calledOnce(netatmoHandler.loadDevices); + sinon.assert.called(netatmoHandler.updateValues); + }); + + it('should refresh device values without device existing in Gladys', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + + await netatmoHandler.refreshNetatmoValues(); + + clock.restore(); + 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.refreshNetatmoValues(); + + clock.restore(); + 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/tessie/lib/netatmo.refreshingTokens.test.js b/server/test/services/tessie/lib/netatmo.refreshingTokens.test.js new file mode 100644 index 0000000000..3216e3e1a6 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.refreshingTokens.test.js @@ -0,0 +1,211 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +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', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + 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(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + 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, + }; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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 () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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(Error); + expect(e.message).to.include('HTTP error 400 - {"error":"invalid_request"}'); + 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'; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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/tessie/lib/netatmo.retrieveToken.test.js b/server/test/services/tessie/lib/netatmo.retrieveToken.test.js new file mode 100644 index 0000000000..d848fdc862 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.retrieveToken.test.js @@ -0,0 +1,236 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +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; + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + 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(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + 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, + }; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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); + sinon.assert.notCalled(netatmoHandler.pollRefreshingValues); + sinon.assert.calledOnce(netatmoHandler.pollRefreshingToken); + }); + + it('should retrieve tokens and launch pollRefreshingValues', async () => { + const tokens = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expire_in: 3600, + }; + netatmoHandler.configuration.energyApi = true; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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); + sinon.assert.calledOnce(netatmoHandler.pollRefreshingValues); + sinon.assert.calledOnce(netatmoHandler.pollRefreshingToken); + }); + + 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'; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(400, { error: 'invalid_request' }); + + try { + await netatmoHandler.retrieveTokens(bodyFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.include('HTTP error 400 - {"error":"invalid_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(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'; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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/tessie/lib/netatmo.saveConfiguration.test.js b/server/test/services/tessie/lib/netatmo.saveConfiguration.test.js new file mode 100644 index 0000000000..a06bd34836 --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.saveStatus.test.js b/server/test/services/tessie/lib/netatmo.saveStatus.test.js new file mode 100644 index 0000000000..174e0eaef5 --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.setTokens.test.js b/server/test/services/tessie/lib/netatmo.setTokens.test.js new file mode 100644 index 0000000000..3cd2067d7c --- /dev/null +++ b/server/test/services/tessie/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/tessie/lib/netatmo.setValue.test.js b/server/test/services/tessie/lib/netatmo.setValue.test.js new file mode 100644 index 0000000000..82193461d9 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.setValue.test.js @@ -0,0 +1,207 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +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', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'connected'; + netatmoHandler.accessToken = 'valid_access_token'; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should set device value successfully', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/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/tessie/lib/netatmo.updateValues.test.js b/server/test/services/tessie/lib/netatmo.updateValues.test.js new file mode 100644 index 0000000000..74e0114d1d --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.updateValues.test.js @@ -0,0 +1,166 @@ +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', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should not update values if type not supported', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[devicesGladys.length - 1])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[devicesNetatmo.length - 1])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + sinon.assert.notCalled(netatmoHandler.gladys.event.emit); + }); + + it('should not update values if feature not found', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + deviceGladys.features = []; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + sinon.assert.notCalled(netatmoHandler.gladys.event.emit); + }); + + it('should handle invalid external_id format on prefix', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalIdFake = deviceGladys.external_id.replace('netatmo:', ''); + + try { + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, 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 deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalIdFake = 'netatmo:'; + + try { + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, 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 () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladys.features.length); + }); + + it('should save all values thermostat according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[1])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[1])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + }); + + it('should save all values valves according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[3])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[3])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + }); + + it('should save all values weather station according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[4])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[4])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(10); + }); + + it('should save all values module outdoor according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[5])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[5])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + }); + + it('should save all values module anemometer according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[6])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[6])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(8); + }); + + it('should save all values module rain gauge according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[7])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[7])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(5); + }); + + it('should save all values module indoor according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[8])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[8])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(8); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js new file mode 100644 index 0000000000..b8fc239a8a --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js @@ -0,0 +1,142 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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 NAMain features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[4])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[4])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.temperature = undefined; + deviceNetatmoMock.co2 = undefined; + deviceNetatmoMock.humidity = undefined; + deviceNetatmoMock.noise = undefined; + deviceNetatmoMock.pressure = undefined; + deviceNetatmoMock.absolute_pressure = undefined; + deviceNetatmoMock.wifi_strength = undefined; + await netatmoHandler.updateNAMain(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 20.1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:therm_measured_temperature`, + state: 20.5, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(7), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:min_temp`, + state: 18.5, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(8), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_temp`, + state: 20.7, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + delete deviceNetatmoMock.room; + + await netatmoHandler.updateNAMain(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 20.2, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:temperature', + state: 20.2, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:co2', + state: 500, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:humidity', + state: 50, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:noise', + state: 32, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:pressure', + state: 1022, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:absolute_pressure', + state: 1007, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(6).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:wifi_strength', + state: 38, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAMain(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js new file mode 100644 index 0000000000..2a499e3345 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js @@ -0,0 +1,117 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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 Smart Outdoor module NAModule1 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[5])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[5])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.temperature = undefined; + deviceNetatmoMock.humidity = undefined; + deviceNetatmoMock.rf_strength = undefined; + await netatmoHandler.updateNAModule1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 14.2, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(3), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:min_temp`, + state: 5.1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(4), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_temp`, + state: 14.8, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + + await netatmoHandler.updateNAModule1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(4); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 32, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:battery_percent', + state: 32, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:temperature', + state: 14.2, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:humidity', + state: 78, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:rf_strength', + state: 59, + }), + ).to.equal(true); + }); + + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js new file mode 100644 index 0000000000..9ae7a084d1 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js @@ -0,0 +1,130 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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 Smart Outdoor module NAModule2 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[6])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[6])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.wind_strength = undefined; + deviceNetatmoMock.wind_angle = undefined; + deviceNetatmoMock.wind_gust = undefined; + deviceNetatmoMock.wind_gust_angle = undefined; + deviceNetatmoMock.rf_strength = undefined; + await netatmoHandler.updateNAModule2(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:wind_strength`, + state: 1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(5), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_wind_str`, + state: 11, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(6), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_wind_angle`, + state: 137, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + + await netatmoHandler.updateNAModule2(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 51, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:battery_percent', + state: 51, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_strength', + state: 1, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_angle', + state: 276, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_gust', + state: 6, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_gust_angle', + state: 303, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:rf_strength', + state: 120, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule2(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js new file mode 100644 index 0000000000..b32059ebfc --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js @@ -0,0 +1,113 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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 NAModule3 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[7])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[7])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.rain = undefined; + deviceNetatmoMock.sum_rain_1 = undefined; + deviceNetatmoMock.sum_rain_24 = undefined; + deviceNetatmoMock.rf_strength = undefined; + await netatmoHandler.updateNAModule3(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:rain`, + state: 0.101, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(3), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:sum_rain_24`, + state: 0.1, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + + await netatmoHandler.updateNAModule3(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(5); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 52, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:battery_percent', + state: 52, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:rain', + state: 0.101, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:sum_rain_1', + state: 0.505, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:rf_strength', + state: 96, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule3(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js new file mode 100644 index 0000000000..c1bfa47867 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js @@ -0,0 +1,129 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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 Smart Indoor module NAModule4 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[8])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[8])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.temperature = undefined; + deviceNetatmoMock.co2 = undefined; + deviceNetatmoMock.humidity = undefined; + deviceNetatmoMock.rf_strength = undefined; + + await netatmoHandler.updateNAModule4(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 18.1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(2), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:therm_measured_temperature`, + state: 19, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(5), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:min_temp`, + state: 15.7, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(6), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_temp`, + state: 18.2, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + delete deviceNetatmoMock.room; + + await netatmoHandler.updateNAModule4(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(5); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 41, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:battery_percent', + state: 41, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:temperature', + state: 18.2, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:co2', + state: 922, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:humidity', + state: 60, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:rf_strength', + state: 72, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule4(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js new file mode 100644 index 0000000000..d640237b59 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js @@ -0,0 +1,117 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should update device values correctly for a plug device connected on boiler', async () => { + await netatmoHandler.updateNAPlug(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.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 () => { + deviceNetatmoMock.plug_connected_boiler = undefined; + + await netatmoHandler.updateNAPlug(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.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 () => { + deviceNetatmoMock.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(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js b/server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js new file mode 100644 index 0000000000..e51ec51b49 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[1])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[1])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases', async () => { + await netatmoHandler.updateNATherm1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.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 () => { + deviceNetatmoMock.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(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNRV.test.js b/server/test/services/tessie/lib/update/netatmo.updateNRV.test.js new file mode 100644 index 0000000000..003a33b648 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNRV.test.js @@ -0,0 +1,118 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(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 NRV features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[3])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[3])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with heating power request', async () => { + await netatmoHandler.updateNRV(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 90, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:battery_percent', + state: 90, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:therm_measured_temperature', + state: 18.5, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:therm_setpoint_temperature', + state: 19, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:open_window', + state: 0, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:rf_strength', + state: 80, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:heating_power_request', + state: 1, + }), + ).to.equal(true); + }); + + it('should save all values according to all cases without heating power request', async () => { + deviceNetatmoMock.room.heating_power_request = 0; + + await netatmoHandler.updateNRV(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:heating_power_request`, + state: 0, + }); + }); + + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNRV(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/netatmo.convertDevices.mock.test.json b/server/test/services/tessie/netatmo.convertDevices.mock.test.json new file mode 100644 index 0000000000..b0ff9b1265 --- /dev/null +++ b/server/test/services/tessie/netatmo.convertDevices.mock.test.json @@ -0,0 +1,996 @@ +[ + { + "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 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": "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": "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": "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": "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": "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 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": "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": "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": "Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx", + "selector": "netatmo:09:00:00:xx:xx:xx", + "model": "NRV", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:battery_percent", + "selector": "netatmo:09: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": "Link RF quality - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:rf_strength", + "selector": "netatmo:09: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": "Temperature - room Office", + "external_id": "netatmo:09:00:00:xx:xx:xx:therm_measured_temperature", + "selector": "netatmo:09: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 - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:therm_setpoint_temperature", + "selector": "netatmo:09: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 - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:open_window", + "selector": "netatmo:09: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": "Heating power request - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:heating_power_request", + "selector": "netatmo:09:00:00:xx:xx:xx:heating_power_request", + "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:yy:yy:yy" + }, + { + "name": "plug_name", + "value": "Relais Salon Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Office" + } + ] + }, + { + "name": "Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj", + "selector": "netatmo:70:ee:50:jj:jj:jj", + "model": "NAMain", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link Wifi quality - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:wifi_strength", + "selector": "netatmo:70:ee:50:jj:jj:jj:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:temperature", + "selector": "netatmo:70:ee:50:jj:jj:jj:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - room Office", + "external_id": "netatmo:70:ee:50:jj:jj:jj:therm_measured_temperature", + "selector": "netatmo:70:ee:50:jj:jj:jj:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Minimum in Office", + "external_id": "netatmo:70:ee:50:jj:jj:jj:min_temp", + "selector": "netatmo:70:ee:50:jj:jj:jj:min_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Maximum in Office", + "external_id": "netatmo:70:ee:50:jj:jj:jj:max_temp", + "selector": "netatmo:70:ee:50:jj:jj:jj:max_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "CO2 - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:co2", + "selector": "netatmo:70:ee:50:jj:jj:jj:co2", + "category": "co2-sensor", + "type": "integer", + "unit": "ppm", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 5000 + }, + { + "name": "Humidity - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:humidity", + "selector": "netatmo:70:ee:50:jj:jj:jj:humidity", + "category": "humidity-sensor", + "type": "decimal", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Noise - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:noise", + "selector": "netatmo:70:ee:50:jj:jj:jj:noise", + "category": "noise-sensor", + "type": "integer", + "unit": "decibel", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 250 + }, + { + "name": "Pressure - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:pressure", + "selector": "netatmo:70:ee:50:jj:jj:jj:pressure", + "category": "pressure-sensor", + "type": "integer", + "unit": "milli-bar", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -1000, + "max": 2000 + }, + { + "name": "Absolute pressure - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:absolute_pressure", + "selector": "netatmo:70:ee:50:jj:jj:jj:absolute_pressure", + "category": "pressure-sensor", + "type": "integer", + "unit": "milli-bar", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -1000, + "max": 2000 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[\"02:00:00:yy:yy:yy\",\"05:00:00:yy:yy:yy\",\"06:00:00:yy:yy:yy\",\"03:00:00:yy:yy:yy\"]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Office" + } + ] + }, + { + "name": "Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy", + "selector": "netatmo:02:00:00:yy:yy:yy", + "model": "NAModule1", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:02:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:02:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:temperature", + "selector": "netatmo:02:00:00:yy:yy:yy:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Minimum in Outdoor", + "external_id": "netatmo:02:00:00:yy:yy:yy:min_temp", + "selector": "netatmo:02:00:00:yy:yy:yy:min_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Maximum in Outdoor", + "external_id": "netatmo:02:00:00:yy:yy:yy:max_temp", + "selector": "netatmo:02:00:00:yy:yy:yy:max_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Humidity - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:humidity", + "selector": "netatmo:02:00:00:yy:yy:yy:humidity", + "category": "humidity-sensor", + "type": "decimal", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Outdoor" + } + ] + }, + { + "name": "Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy", + "selector": "netatmo:06:00:00:yy:yy:yy", + "model": "NAModule2", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:06:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:06:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Wind strength - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_strength", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_strength", + "category": "speed-sensor", + "type": "integer", + "unit": "kilometer-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 300 + }, + { + "name": "Wind angle - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_angle", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_angle", + "category": "angle-sensor", + "type": "integer", + "unit": "degree", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 360 + }, + { + "name": "Gust strength - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_gust", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_gust", + "category": "speed-sensor", + "type": "integer", + "unit": "kilometer-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 300 + }, + { + "name": "Gust angle - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_gust_angle", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_gust_angle", + "category": "angle-sensor", + "type": "integer", + "unit": "degree", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 360 + }, + { + "name": "Maximum wind strength - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:max_wind_str", + "selector": "netatmo:06:00:00:yy:yy:yy:max_wind_str", + "category": "speed-sensor", + "type": "integer", + "unit": "kilometer-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 300 + }, + { + "name": "Maximum wind angle - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:max_wind_angle", + "selector": "netatmo:06:00:00:yy:yy:yy:max_wind_angle", + "category": "angle-sensor", + "type": "integer", + "unit": "degree", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 360 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Outdoor" + } + ] + }, + { + "name": "STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy", + "selector": "netatmo:05:00:00:yy:yy:yy", + "model": "NAModule3", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:05:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:05:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Current rain - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:rain", + "selector": "netatmo:05:00:00:yy:yy:yy:rain", + "category": "precipitation-sensor", + "type": "decimal", + "unit": "mm", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Precipitation / 1h - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:sum_rain_1", + "selector": "netatmo:05:00:00:yy:yy:yy:sum_rain_1", + "category": "precipitation-sensor", + "type": "decimal", + "unit": "millimeter-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Sum rain / 24h - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:sum_rain_24", + "selector": "netatmo:05:00:00:yy:yy:yy:sum_rain_24", + "category": "precipitation-sensor", + "type": "decimal", + "unit": "millimeter-per-day", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Outdoor" + } + ] + }, + { + "name": "STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy", + "selector": "netatmo:03:00:00:yy:yy:yy", + "model": "NAModule4", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:03:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:03:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:temperature", + "selector": "netatmo:03:00:00:yy:yy:yy:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - room Kitchen", + "external_id": "netatmo:03:00:00:yy:yy:yy:therm_measured_temperature", + "selector": "netatmo:03:00:00:yy:yy:yy:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Minimum in Kitchen", + "external_id": "netatmo:03:00:00:yy:yy:yy:min_temp", + "selector": "netatmo:03:00:00:yy:yy:yy:min_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Maximum in Kitchen", + "external_id": "netatmo:03:00:00:yy:yy:yy:max_temp", + "selector": "netatmo:03:00:00:yy:yy:yy:max_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "CO2 - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:co2", + "selector": "netatmo:03:00:00:yy:yy:yy:co2", + "category": "co2-sensor", + "type": "integer", + "unit": "ppm", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 5000 + }, + { + "name": "Humidity - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:humidity", + "selector": "netatmo:03:00:00:yy:yy:yy:humidity", + "category": "humidity-sensor", + "type": "decimal", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Kitchen" + } + ] + }, + { + "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/tessie/netatmo.discoverDevices.mock.test.json b/server/test/services/tessie/netatmo.discoverDevices.mock.test.json new file mode 100644 index 0000000000..8acf4b751e --- /dev/null +++ b/server/test/services/tessie/netatmo.discoverDevices.mock.test.json @@ -0,0 +1,108 @@ +[ + { + "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 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": "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": "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, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + } +] diff --git a/server/test/services/tessie/netatmo.getThermostat.mock.test.json b/server/test/services/tessie/netatmo.getThermostat.mock.test.json new file mode 100644 index 0000000000..17a830f108 --- /dev/null +++ b/server/test/services/tessie/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/tessie/netatmo.getWeatherStation.mock.test.json b/server/test/services/tessie/netatmo.getWeatherStation.mock.test.json new file mode 100644 index 0000000000..c0687bae9a --- /dev/null +++ b/server/test/services/tessie/netatmo.getWeatherStation.mock.test.json @@ -0,0 +1,149 @@ +{ + "devices": [ + { + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "type": "NAMain", + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "modules": [ + { + "_id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "reachable": true, + "firmware": 53, + "last_message": 1706525865, + "last_seen": 1706517393, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + } + }, + { + "_id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "reachable": true, + "firmware": 27, + "last_message": 1706723822, + "last_seen": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + } + }, + { + "_id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "battery_percent": 52, + "reachable": false, + "firmware": 14, + "last_message": 1706021182, + "last_seen": 1705945317, + "rf_status": 96, + "battery_vp": 4944, + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + } + }, + { + "_id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "reachable": true, + "firmware": 53, + "last_message": 1706517399, + "last_seen": 1706517393, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + } + } + ] + } + ], + "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/tessie/netatmo.homesdata.mock.test.json b/server/test/services/tessie/netatmo.homesdata.mock.test.json new file mode 100644 index 0000000000..ccf48b338b --- /dev/null +++ b/server/test/services/tessie/netatmo.homesdata.mock.test.json @@ -0,0 +1,181 @@ +{ + "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", + "09: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 + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "bridge": "70:ee:50:yy:yy:yy" + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"] + }, + { + "id": "02:00:00:xx:xx:xx", + "type": "NAModule1", + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + }, + { + "id": "03:00:00:xx:xx:xx", + "type": "NAModule4", + "name": "STARK House kitchen hygrometer", + "setup_date": 1605564990, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + } + ], + "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/tessie/netatmo.homestatus.mock.test.json b/server/test/services/tessie/netatmo.homestatus.mock.test.json new file mode 100644 index 0000000000..5e1eb68fc7 --- /dev/null +++ b/server/test/services/tessie/netatmo.homestatus.mock.test.json @@ -0,0 +1,176 @@ +{ + "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 + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy" + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8 + }, + { + "id": "02:00:00:xx:xx:xx", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706525865, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78 + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303 + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0, + "sum_rain_1": 0 + }, + { + "id": "03:00:00:xx:xx:xx", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4932, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 70, + "last_seen": 1706464227, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706464124, + "temperature": 18.1, + "co2": 854, + "humidity": 60 + } + ], + "persons": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "last_seen": 1702874136, + "out_of_sight": true + } + ] + } +} diff --git a/server/test/services/tessie/netatmo.loadDevices.mock.test.json b/server/test/services/tessie/netatmo.loadDevices.mock.test.json new file mode 100644 index 0000000000..ed3816f982 --- /dev/null +++ b/server/test/services/tessie/netatmo.loadDevices.mock.test.json @@ -0,0 +1,805 @@ +[ + { + "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, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "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 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "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", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"], + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "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": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8, + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20.5, + "therm_setpoint_start_time": 1706461202, + "therm_setpoint_mode": "schedule" + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706525865, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78, + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "_id": "02:00:00:yy:yy:yy", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "firmware": 53, + "last_message": 1706525865, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303, + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "_id": "06:00:00:yy:yy:yy", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "firmware": 27, + "last_message": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0.101, + "sum_rain_1": 0.505, + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "_id": "05:00:00:yy:yy:yy", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "battery_percent": 52, + "firmware": 14, + "last_message": 1706021182, + "rf_status": 96, + "battery_vp": 4944, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4933, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 72, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706517393, + "temperature": 18.2, + "co2": 922, + "humidity": 60, + "name": "STARK House kitchen hygrometer", + "setup_date": 1608046641, + "room_id": "8765432109", + "_id": "03:00:00:yy:yy:yy", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "firmware": 53, + "last_message": 1706517399, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Kitchen", + "type": "home_kitchen", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "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": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + } + }, + { + "_id": "70:ee:50:mm:mm:mm", + "station_name": "Maison STARK Secondaire (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "type": "NAMain", + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK Secondaire", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "modules": [], + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "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/tessie/netatmo.loadDevicesComplete.mock.test.json b/server/test/services/tessie/netatmo.loadDevicesComplete.mock.test.json new file mode 100644 index 0000000000..9aed5c08d4 --- /dev/null +++ b/server/test/services/tessie/netatmo.loadDevicesComplete.mock.test.json @@ -0,0 +1,979 @@ +[ + { + "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": ["04:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000589, + "firmware": 200, + "last_status_store": 1602798842, + "plug_connected_boiler": true, + "wifi_status": 45, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true, + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "anticipating": false, + "battery_percent": 50, + "battery_vp": 3000, + "categoryAPI": "Energy", + "apiNotConfigured": true, + "event_history": {}, + "firmware": 60, + "last_message": 1602798840, + "last_therm_seen": 1602798840, + "measured": { + "setpoint_temp": 19, + "temperature": 19, + "time": 1602798668 + }, + "module_name": "Thermostat Test", + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "rf_status": 60, + "setpoint": { + "setpoint_endtime": 1703096987, + "setpoint_mode": "manual", + "setpoint_temp": 14 + }, + "setpoint_history": [], + "therm_orientation": 1, + "therm_program_list": [], + "therm_relay_cmd": 100, + "type": "NATherm1" + } + ] + }, + { + "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": 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 }, + "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", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "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, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "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": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8, + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20.5, + "therm_setpoint_start_time": 1706461202, + "therm_setpoint_mode": "schedule" + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "modules": [ + { + "_id": "02:00:00:yy:yy:yy", + "battery_percent": 32, + "battery_vp": 4784, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "Humidity": 78, + "Temperature": 14.2, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "max_temp": 14.8, + "min_temp": 5.1, + "temp_trend": "up", + "time_utc": 1706525814 + }, + "data_type": ["Temperature", "Humidity"], + "firmware": 53, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706525865, + "last_seen": 1706517393, + "last_setup": 1605564992, + "module_name": "Outdoor Hygrometer", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": true, + "rf_status": 59, + "type": "NAModule1" + }, + { + "_id": "06:00:00:yy:yy:yy", + "battery_percent": 51, + "battery_vp": 4993, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "GustAngle": 303, + "GustStrength": 6, + "WindAngle": 270, + "WindStrength": 1, + "date_max_wind_str": 1706714453, + "max_wind_angle": 137, + "max_wind_str": 11, + "time_utc": 1706723822 + }, + "data_type": ["Wind"], + "firmware": 27, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706723822, + "last_seen": 1706723822, + "last_setup": 1605604559, + "module_name": "Anemometer STARK Home", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": true, + "rf_status": 70, + "type": "NAModule2" + }, + { + "_id": "05:00:00:yy:yy:yy", + "battery_percent": 52, + "battery_vp": 4944, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1, + "time_utc": 1608764760 + }, + "data_type": ["Rain"], + "firmware": 14, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706021182, + "last_seen": 1705945317, + "last_setup": 1605604312, + "module_name": "STARK House Rain Gauge", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": false, + "rf_status": 96, + "type": "NAModule3" + }, + { + "_id": "03:00:00:yy:yy:yy", + "battery_percent": 41, + "battery_vp": 4935, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "CO2": 922, + "Humidity": 60, + "Temperature": 18.1, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "max_temp": 18.2, + "min_temp": 15.7, + "temp_trend": "stable", + "time_utc": 1706517393 + }, + "data_type": ["Temperature", "CO2", "Humidity"], + "firmware": 53, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706517399, + "last_seen": 1706517393, + "last_setup": 1608046641, + "module_name": "STARK House kitchen hygrometer", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": true, + "rf_status": 72, + "type": "NAModule4" + } + ] + }, + { + "id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78, + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "_id": "02:00:00:yy:yy:yy", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "firmware": 53, + "last_message": 1706525865, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "home_id": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303, + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "_id": "06:00:00:yy:yy:yy", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "firmware": 27, + "last_message": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "home_id": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0, + "sum_rain_1": 0, + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "_id": "05:00:00:yy:yy:yy", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "battery_percent": 52, + "firmware": 14, + "last_message": 1706021182, + "rf_status": 96, + "battery_vp": 4944, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4933, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 72, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706517393, + "temperature": 18.2, + "co2": 922, + "humidity": 60, + "name": "STARK House kitchen hygrometer", + "setup_date": 1608046641, + "room_id": "8765432109", + "_id": "03:00:00:yy:yy:yy", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "firmware": 53, + "last_message": 1706517399, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Kitchen", + "type": "home_kitchen", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "home_id": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "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/tessie/netatmo.loadDevicesDetails.mock.test.json b/server/test/services/tessie/netatmo.loadDevicesDetails.mock.test.json new file mode 100644 index 0000000000..561f587862 --- /dev/null +++ b/server/test/services/tessie/netatmo.loadDevicesDetails.mock.test.json @@ -0,0 +1,740 @@ +[ + { + "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, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "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 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "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, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "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": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8, + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20.5, + "therm_setpoint_start_time": 1706461202, + "therm_setpoint_mode": "schedule" + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706525865, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78, + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "_id": "02:00:00:yy:yy:yy", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "firmware": 53, + "last_message": 1706525865, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303, + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "_id": "06:00:00:yy:yy:yy", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "firmware": 27, + "last_message": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0, + "sum_rain_1": 0, + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "_id": "05:00:00:yy:yy:yy", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "battery_percent": 52, + "firmware": 14, + "last_message": 1706021182, + "rf_status": 96, + "battery_vp": 4944, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4933, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 72, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706517393, + "temperature": 18.2, + "co2": 922, + "humidity": 60, + "name": "STARK House kitchen hygrometer", + "setup_date": 1608046641, + "room_id": "8765432109", + "_id": "03:00:00:yy:yy:yy", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "firmware": 53, + "last_message": 1706517399, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Kitchen", + "type": "home_kitchen", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "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/tessie/netatmo.loadThermostatDetails.mock.test.json b/server/test/services/tessie/netatmo.loadThermostatDetails.mock.test.json new file mode 100644 index 0000000000..46f340a7af --- /dev/null +++ b/server/test/services/tessie/netatmo.loadThermostatDetails.mock.test.json @@ -0,0 +1,126 @@ +{ + "plugs": [ + { + "_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 + }, + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + ], + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + ], + "thermostats": [ + { + "_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 + }, + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + ] +} diff --git a/server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json b/server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json new file mode 100644 index 0000000000..b74c46c95d --- /dev/null +++ b/server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json @@ -0,0 +1,578 @@ +{ + "weatherStations": [ + { + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "type": "NAMain", + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "modules": [ + { + "_id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "reachable": true, + "firmware": 53, + "last_message": 1706525865, + "last_seen": 1706517393, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "reachable": true, + "firmware": 27, + "last_message": 1706723822, + "last_seen": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "battery_percent": 52, + "reachable": false, + "firmware": 14, + "last_message": 1706021182, + "last_seen": 1705945317, + "rf_status": 96, + "battery_vp": 4944, + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "reachable": true, + "firmware": 53, + "last_message": 1706517399, + "last_seen": 1706517393, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + } + ], + "categoryAPI": "Weather", + "apiNotConfigured": true + } + ], + "modulesWeatherStations": [ + { + "_id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "reachable": true, + "firmware": 53, + "last_message": 1706525865, + "last_seen": 1706517393, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "reachable": true, + "firmware": 27, + "last_message": 1706723822, + "last_seen": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "battery_percent": 52, + "reachable": false, + "firmware": 14, + "last_message": 1706021182, + "last_seen": 1705945317, + "rf_status": 96, + "battery_vp": 4944, + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "reachable": true, + "firmware": 53, + "last_message": 1706517399, + "last_seen": 1706517393, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + } + ] +} diff --git a/server/test/services/tessie/netatmo.mock.test.js b/server/test/services/tessie/netatmo.mock.test.js new file mode 100644 index 0000000000..60d1f43f50 --- /dev/null +++ b/server/test/services/tessie/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/test/utils/units.test.js b/server/test/utils/units.test.js index f6aae879a6..a9dbc91f46 100644 --- a/server/test/utils/units.test.js +++ b/server/test/utils/units.test.js @@ -31,7 +31,7 @@ describe('hslToRgb', () => { }); }); -describe('checkAndConvertUnit', () => { +describe('checkAndConvertUnit distances', () => { it('converts kilometers to miles (US preference)', () => { const result = checkAndConvertUnit(10, DEVICE_FEATURE_UNITS.KM, MEASUREMENT_UNITS.US); expect(result.value).to.be.closeTo(6.21, 0.01); @@ -153,6 +153,34 @@ describe('checkAndConvertUnit', () => { }); }); +describe('checkAndConvertUnit pressure', () => { + it('converts bar to psi (US preference)', () => { + const result = checkAndConvertUnit(10, DEVICE_FEATURE_UNITS.BAR, MEASUREMENT_UNITS.US); + expect(result.value).to.be.closeTo(145.0, 0.1); + expect(result.unit).to.equal(DEVICE_FEATURE_UNITS.PSI); + }); + it('converts psi to bar (metric preference)', () => { + const result = checkAndConvertUnit(10, DEVICE_FEATURE_UNITS.PSI, MEASUREMENT_UNITS.METRIC); + expect(result.value).to.be.closeTo(0.69, 0.01); + expect(result.unit).to.equal(DEVICE_FEATURE_UNITS.BAR); + }); + it('converts kPa to bar (metric preference)', () => { + const result = checkAndConvertUnit(10, DEVICE_FEATURE_UNITS.KILO_PASCAL, MEASUREMENT_UNITS.METRIC); + expect(result.value).to.be.closeTo(0.1, 0.01); + expect(result.unit).to.equal(DEVICE_FEATURE_UNITS.BAR); + }); + it('converts kPa to psi (US preference)', () => { + const result = checkAndConvertUnit(10, DEVICE_FEATURE_UNITS.KILO_PASCAL, MEASUREMENT_UNITS.US); + expect(result.value).to.be.closeTo(1.45, 0.01); + expect(result.unit).to.equal(DEVICE_FEATURE_UNITS.PSI); + }); + it('converts mbar to psi (US preference)', () => { + const result = checkAndConvertUnit(10, DEVICE_FEATURE_UNITS.MILLIBAR, MEASUREMENT_UNITS.US); + expect(result.value).to.be.closeTo(0.145, 0.01); + expect(result.unit).to.equal(DEVICE_FEATURE_UNITS.PSI); + }); +}); + describe('smartRound', () => { it('returns value as is for abs(value) < 1', () => { expect(smartRound(0.123)).to.equal(0.123); diff --git a/server/utils/constants.js b/server/utils/constants.js index 7d1bbc2f10..78772d1611 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -853,6 +853,7 @@ const DEVICE_FEATURE_TYPES = { // Features related to the vehicle's climate control CLIMATE_ON: 'climate-on', // Climate system activation (binary - command with return status) INDOOR_TEMPERATURE: 'indoor-temperature', // Cabin temperature in °C (integer - sensor) + OUTSIDE_TEMPERATURE: 'outside-temperature', // Outside temperature in °C (integer - sensor) TARGET_TEMPERATURE: 'target-temperature', // Desired cabin temperature in °C (integer - command) }, ELECTRICAL_VEHICLE_COMMAND: { @@ -874,6 +875,7 @@ const DEVICE_FEATURE_TYPES = { // Features related to the physical state of the vehicle DOOR_OPENED: 'door-opened', // Door open state (binary - sensor) ODOMETER: 'odometer', // Total distance traveled in km or miles (integer - sensor) + TIRE_PRESSURE: 'tire-pressure', // Tire pressure in bar (decimal - sensor) WINDOW_OPENED: 'window-opened', // Window open state (binary - sensor) }, }; @@ -888,6 +890,7 @@ const DEVICE_FEATURE_UNITS = { // Pressure units PASCAL: 'pascal', HECTO_PASCAL: 'hPa', + KILO_PASCAL: 'kPa', BAR: 'bar', PSI: 'psi', MILLIBAR: 'milli-bar', @@ -1030,6 +1033,7 @@ const DEVICE_FEATURE_UNITS_BY_CATEGORY = { [DEVICE_FEATURE_CATEGORIES.PRESSURE_SENSOR]: [ DEVICE_FEATURE_UNITS.PASCAL, DEVICE_FEATURE_UNITS.HECTO_PASCAL, + DEVICE_FEATURE_UNITS.KILO_PASCAL, DEVICE_FEATURE_UNITS.BAR, DEVICE_FEATURE_UNITS.PSI, DEVICE_FEATURE_UNITS.MILLIBAR, @@ -1096,7 +1100,13 @@ const DEVICE_FEATURE_UNITS_BY_CATEGORY = { DEVICE_FEATURE_UNITS.KM_PER_KILOWATT_HOUR, DEVICE_FEATURE_UNITS.MILE_PER_KILOWATT_HOUR, ], - [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_STATE]: [DEVICE_FEATURE_UNITS.KM, DEVICE_FEATURE_UNITS.MILE], + [DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_STATE]: [ + DEVICE_FEATURE_UNITS.KM, + DEVICE_FEATURE_UNITS.MILE, + DEVICE_FEATURE_UNITS.BAR, + DEVICE_FEATURE_UNITS.PSI, + DEVICE_FEATURE_UNITS.KILO_PASCAL, + ], [DEVICE_FEATURE_CATEGORIES.VOLUME_SENSOR]: [ DEVICE_FEATURE_UNITS.LITER, DEVICE_FEATURE_UNITS.MILLILITER, @@ -1169,25 +1179,6 @@ const DEVICE_FEATURE_UNITS_BY_CATEGORY = { const MEASUREMENT_UNITS = { US: 'us', METRIC: 'metric', - DEVICE_FEATURE_UNITS: [ - DEVICE_FEATURE_UNITS.MM, - DEVICE_FEATURE_UNITS.CM, - DEVICE_FEATURE_UNITS.M, - DEVICE_FEATURE_UNITS.KM, - DEVICE_FEATURE_UNITS.INCH, - DEVICE_FEATURE_UNITS.FEET, - DEVICE_FEATURE_UNITS.MILE, - DEVICE_FEATURE_UNITS.KILOMETER_PER_HOUR, - DEVICE_FEATURE_UNITS.METER_PER_SECOND, - DEVICE_FEATURE_UNITS.KM_PER_KILOWATT_HOUR, - DEVICE_FEATURE_UNITS.KILOWATT_HOUR_PER_100_KM, - DEVICE_FEATURE_UNITS.WATT_HOUR_PER_KM, - DEVICE_FEATURE_UNITS.WATT_HOUR_PER_MILE, - DEVICE_FEATURE_UNITS.KILOWATT_HOUR_PER_100_MILE, - DEVICE_FEATURE_UNITS.MILE_PER_KILOWATT_HOUR, - DEVICE_FEATURE_UNITS.FEET_PER_SECOND, - DEVICE_FEATURE_UNITS.MILE_PER_HOUR, - ], }; const ACTIONS_STATUS = { @@ -1318,6 +1309,15 @@ const WEBSOCKET_MESSAGE_TYPES = { STATUS_CHANGE: 'nodered.status-change', MQTT_ERROR: 'nodered.mqtt-error', }, + TESSIE: { + STATUS: 'tessie.status', + ERROR: { + CONNECTED: 'tessie.error-connected', + CONNECTING: 'tessie.error-connecting', + PROCESSING_TOKEN: 'tessie.error-processing-token', + WEBSOCKET: 'tessie.error-websocket', + }, + }, }; const DASHBOARD_TYPE = { diff --git a/server/utils/unit-conversions.js b/server/utils/unit-conversions.js index f5f7dec0a6..66880b8f44 100644 --- a/server/utils/unit-conversions.js +++ b/server/utils/unit-conversions.js @@ -60,12 +60,25 @@ const DISTANCE_UNIT_CONVERSIONS = { }, }; +const PRESSURE_UNIT_CONVERSIONS = { + [MEASUREMENT_UNITS.US]: { + [DEVICE_FEATURE_UNITS.BAR]: { unit: DEVICE_FEATURE_UNITS.PSI, convert: (bar) => bar * 14.5037738007218 }, + [DEVICE_FEATURE_UNITS.MILLIBAR]: { unit: DEVICE_FEATURE_UNITS.PSI, convert: (mbar) => mbar * 0.0145037738007218 }, + [DEVICE_FEATURE_UNITS.KILO_PASCAL]: { unit: DEVICE_FEATURE_UNITS.PSI, convert: (kpa) => kpa * 0.145037738007218 }, + }, + [MEASUREMENT_UNITS.METRIC]: { + [DEVICE_FEATURE_UNITS.PSI]: { unit: DEVICE_FEATURE_UNITS.BAR, convert: (psi) => psi / 14.5037738007218 }, + [DEVICE_FEATURE_UNITS.KILO_PASCAL]: { unit: DEVICE_FEATURE_UNITS.BAR, convert: (kpa) => kpa / 100 }, + }, +}; const UNIT_CONVERSIONS = { [MEASUREMENT_UNITS.US]: { ...DISTANCE_UNIT_CONVERSIONS[MEASUREMENT_UNITS.US], + ...PRESSURE_UNIT_CONVERSIONS[MEASUREMENT_UNITS.US], }, [MEASUREMENT_UNITS.METRIC]: { ...DISTANCE_UNIT_CONVERSIONS[MEASUREMENT_UNITS.METRIC], + ...PRESSURE_UNIT_CONVERSIONS[MEASUREMENT_UNITS.METRIC], }, }; diff --git a/server/utils/units.js b/server/utils/units.js index 27b0fbd364..1edbeb0f5b 100644 --- a/server/utils/units.js +++ b/server/utils/units.js @@ -98,12 +98,12 @@ function smartRound(value) { /** * @description Converts a value from one unit to another according to the user's preference. * @param {number} value - Value to convert. - * @param {string} fromUnit - Original unit (e.g. 'km', 'mile', 'km/h', ...). + * @param {string} fromUnit - Original unit (e.g. 'km', 'mile', 'km/h', 'bar', 'psi', 'kPa', ...). * @param {string} userPreference - User preference ('us' or 'metric'). * @returns {{ value: number, unit: string }} Object containing the converted value and the target unit. * @example * const result = convertUnit(10, DEVICE_FEATURE_UNITS.KM, SYSTEM_UNITS.US); - * // result = { value: 6.21, unit: 'mile' } + * // returns = { value: 6.21, unit: 'mile' } */ function checkAndConvertUnit(value, fromUnit, userPreference) { const unitsConvertUserPreference = UNIT_CONVERSIONS[userPreference];