diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 85554c0613..a20863f6dc 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -1220,6 +1220,7 @@ "alreadyCreatedButton": "Bereits erstellt", "deleteButton": "Löschen", "unmanagedModelButton": "Modell nicht verwaltet oder verfügbar", + "localModeLimitInfo": "Der lokale Modus wird noch verbessert. Bei manchen Geräten können Befehle oder Status-Updates weiterhin über die Tuya-Cloud laufen, auch wenn der lokale Modus aktiviert ist.", "status": { "notConnected": "Gladys konnte keine Verbindung zum Tuya-Cloud-Account herstellen. Bitte gehe zur ", "setupPageLink": "Tuya-Einrichtungsseite.", @@ -1231,23 +1232,56 @@ "updates": "Nach Updates suchen", "editButton": "Bearbeiten", "noDeviceFound": "Du hast noch keine Tuya-Geräte hinzugefügt.", - "featuresLabel": "Funktionen" + "featuresLabel": "Funktionen", + "idLabel": "Geräte-ID", + "localKeyLabel": "Lokaler Schlüssel", + "protocolVersionLabel": "Protokollversion", + "protocol35OptionUnsupported": "3.5 (Nicht unterstützt)", + "protocolVersionRequired": "Protokoll 3.5 wird noch nicht unterstützt, aber bald verfügbar sein.", + "ipAddressLabel": "IP-Adresse", + "ipModeLocal": "Lokal", + "ipModeCloud": "Cloud", + "localInfoHelp": "Mit der Cloud/Lokal-Schaltfläche wählst du den Kommunikationsmodus. Im lokalen Modus kannst du die lokale IP bearbeiten. Im Cloud-Modus wird die Cloud-IP nur angezeigt (schreibgeschützt).", + "localPollButton": "Lokales DPS abfragen", + "localPollInProgress": "Lokaler Poll läuft... Protokoll {{protocol}}.", + "localPollHelp": "Wenn du das Protokoll kennst, wähle es aus und klicke zum Prüfen. Wenn nicht, klicke direkt für einen vollständigen Scan (dauert länger).", + "localPollRequired": "Lokaler Modus ist aktiv und lokale Einstellungen wurden geändert. Bitte \"Lokales DPS abfragen\" erfolgreich ausführen, bevor du speicherst. Wenn der lokale Modus nicht funktioniert, schalte zurück in den Cloud-Modus.", + "localPollSuccess": "Lokaler Poll OK.", + "localPollError": "Lokaler Poll fehlgeschlagen:", + "productIdLabel": "Produkt-ID", + "productKeyLabel": "Produktschlüssel" }, "discover": { "title": "In deinem Tuya-Cloud-Account erkannte Geräte", "description": "Tuya-Geräte werden automatisch erkannt. Deine Tuya-Geräte müssen zuerst zu deinem Tuya-Cloud-Account hinzugefügt werden.", + "localDiscoveryInfo": "Cloud-ScanAktualisiert die Cloud-Liste. Stelle sicher, dass deine Geräte in Tuya als steuerbar gesetzt sind, sonst bleiben sie schreibgeschützt. Die Testphase ist auf 10 steuerbare Geräte begrenzt; zusätzliche Geräte liefern ggf. nur Statusrückmeldungen.

Lokaler Auto-ScanStartet einen Scan in deinem lokalen Netzwerk (UDP-Broadcast, gleiches Subnetz wie Gladys). Wenn ein Gerät nicht gefunden wird, trage die lokale IP und die Protokollversion ein (oder lasse es leer, wenn unbekannt) und nutze \"Lokales DPS abfragen\", um die lokale Kommunikation zu prüfen.

Nutze Sichern, um ein Gerät zu erstellen, und Aktualisieren, um Änderungen zu übernehmen oder auf lokalen Modus umzuschalten.", + "scanCloudInProgress": "Scan läuft... Cloud-Geräte werden abgerufen. Das kann etwas dauern.", + "scanLocalInProgressConnected": "Scan läuft... Lokale Informationen der Geräte werden abgerufen. Das kann etwas dauern.", + "scanLocalInProgressDisconnected": "Scan läuft... Lokale Geräte werden abgerufen. Das kann etwas dauern.", + "scanCloud": "Cloud-Scan", + "localScanAuto": "Lokaler Auto-Scan", + "udpScanError": "Fehler beim lokalen UDP-Scan.", + "udpScanPortInUse": "Auf den Ports {{ports}} kann nicht gelauscht werden (bereits belegt). Beende Dienste auf diesen Ports und starte den Scan erneut.", "error": "Fehler beim Entdecken von Tuya-Geräten. Bitte überprüfe deine Login-Daten auf der Einrichtungsseite.", "noDeviceFound": "Kein Tuya-Gerät entdeckt.", "scan": "Scannen" }, "setup": { "title": "Tuya-Konfiguration", - "description": "Du kannst Gladys mit deinem Tuya-Cloud-Account verbinden, um die zugehörigen Geräte zu steuern.", - "descriptionCreateAccount": "Du musst einen Account bei Tuya erstellen.", - "descriptionCreateProject": "Danach musst du ein \"Cloud-Projekt\" in deinem Tuya-Account erstellen.", - "descriptionGetKeys": "Du erhältst Zugang zum Zugangsschlüssel und zum Geheimschlüssel.", - "descriptionGetAppAccountUid": "Um deine \"App Account UID\" zu erhalten, musst du zum Abschnitt \"Geräte\" -> \"Tuya App-Account verknüpfen\" gehen und einen Anwendungsaccount hinzufügen.", - "descriptionGetAppAccountUid2": "Sobald das Hinzufügen abgeschlossen ist, findest du deine \"App Account UID\" in der UID-Spalte.", + "cloudTitle": "Cloud", + "description": "Du kannst Gladys mit deinem Tuya-Cloud-Account verbinden, um die zugehörigen Geräte zu steuern. Die Dokumentation findest du im linken Menü oder hier.", + "descriptionCreateAccount": "Du musst einen Account bei Tuya erstellen (Registrieren / Anmelden).", + "descriptionCreateProject": "Danach musst du ein \"Cloud-Projekt\" in deinem Tuya-Account erstellen (Konsole: Tuya IoT Platform).", + "descriptionGetKeys": "Du erhältst Zugriff auf beide Schlüssel: Client ID und Client Secret.", + "descriptionGetAppAccountUid": "Um deine App Account UID zu erhalten, gehe zu \"Geräte\" -> \"Tuya App-Account verknüpfen\" und füge einen App-Account hinzu.", + "descriptionGetAppAccountUid2": "Sobald die Verknüpfung abgeschlossen ist, steht deine App Account UID in der UID-Spalte.", + "descriptionTrial": "Tuya-Cloud-Projekte haben eine Testphase: denke daran, sie regelmäßig zu verlängern, um Unterbrechungen zu vermeiden.", + "descriptionCloudLimit": "Hinweis: Die Tuya-Cloud-Testversion erlaubt bis zu 10 steuerbare Geräte. Weitere Geräte können schreibgeschützt bleiben, bis du ein Upgrade durchführst.", + "descriptionControllable": "Einige Geräte sind standardmäßig schreibgeschützt. Öffne in der Tuya IoT Platform Device Permission und klicke auf Change, um sie steuerbar zu machen.", + "localTitle": "Lokal", + "descriptionLocalMode": "Der lokale Modus kann nach der Cloud-Konfiguration genutzt werden: Trage lokale IP und Protokollversion pro Gerät ein, um lokal abzufragen. Wenn die Geräte im selben Netzwerk wie der Gladys-Host sind, werden IP-Erkennung und Protokollversion automatisch erkannt. So kannst du die Cloud-Verlängerung umgehen, aber für das Hinzufügen neuer Geräte in der Tuya-App bleibt die Cloud nötig.", + "descriptionLocalKeepsApp": "Lokale Steuerung deaktiviert die Steuerung über die Tuya/Smart-Life-App nicht.", + "descriptionCameraLimit": "Kameras: Video-Streaming wird in Gladys derzeit nicht unterstützt.", "endpoints": { "china": "China", "westernAmerica": "Westamerika", @@ -1257,17 +1291,30 @@ "india": "Indien" }, "endpoint": "Endpunkt", - "accessKey": "Access Key", - "accessKeyPlaceholder": "Tuya Zugangsschlüssel eingeben", - "secretKey": "Secret Key", - "secretKeyPlaceholder": "Tuya Geheimschlüssel eingeben", - "appAccountId": "App Account UID", - "appAccountIdPlaceholder": "Tuya App UID eingeben", - "saveLabel": "Konfiguration sichern", + "accessKey": "Client ID", + "accessKeyPlaceholder": "Tuya Client ID eingeben", + "secretKey": "Client Secret", + "secretKeyPlaceholder": "Tuya Client Secret eingeben", + "appAccountId": "App Account User ID", + "appAccountIdPlaceholder": "Tuya App User ID eingeben", + "appUsername": "Smart Life Benutzername (optional)", + "appUsernamePlaceholder": "Smart Life Konto E-Mail oder Telefon", + "saveLabel": "Konfiguration sichern und verbinden", "error": "Beim Sichern der Konfiguration ist ein Fehler aufgetreten.", "connecting": "Konfiguration gesichert. Die Verbindung mit deinem Tuya-Cloud-Account wird jetzt hergestellt …", - "connected": "Du hast dich erfolgreich mit dem Tuya-Cloud-Account verbunden!", - "connectionError": "Fehler beim Verbinden. Bitte überprüfe deine Konfiguration." + "connectionError": "Fehler beim Verbinden. Bitte überprüfe deine Konfiguration.", + "errorInvalidEndpoint": "Der Endpunkt scheint falsch zu sein. Bitte überprüfe den Endpunkt.", + "errorInvalidClientId": "Die Client ID scheint ungültig zu sein. Bitte überprüfe die Client ID.", + "errorInvalidClientSecret": "Das Client Secret scheint ungültig zu sein. Bitte überprüfe das Client Secret.", + "errorInvalidAppAccountUid": "Die App-Account-UID scheint ungültig zu sein. Bitte überprüfe die App-Account-UID.", + "notConfigured": "Tuya-Cloud-Verbindungsdaten fehlen oder sind unvollständig.", + "connectedAfterSave": "Du hast dich erfolgreich mit dem Tuya-Cloud-Account verbunden!", + "connectedStatus": "Mit dem Tuya-Cloud-Account verbunden.", + "disconnectSuccess": "Vom Tuya-Cloud-Account erfolgreich getrennt! Der lokale Dienst funktioniert weiterhin (bei einem Neustart von Gladys wird keine automatische Verbindung hergestellt).", + "disconnectedUnexpected": "Du wurdest von der Tuya-Cloud getrennt! Bitte überprüfe deine Zugangsdaten und den Status deines Tuya IoT Core-Testabos.", + "disconnectedManual": "Von der Tuya-Cloud getrennt! Der lokale Dienst funktioniert weiterhin (bei einem Neustart von Gladys wird keine automatische Verbindung hergestellt).", + "disconnectedMissingConfig": "Von der Tuya-Cloud getrennt.", + "disconnectLabel": "Von der Cloud trennen" }, "error": { "defaultError": "Beim Sichern des Geräts ist ein Fehler aufgetreten.", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ab930030f8..642461e95f 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -1115,6 +1115,7 @@ "alreadyCreatedButton": "Already created", "deleteButton": "Delete", "unmanagedModelButton": "Model not managed or available", + "localModeLimitInfo": "Local mode is still being improved. For some devices, commands or state updates may still use Tuya cloud even if Local mode is selected.", "status": { "notConnected": "Gladys failed to connect to Tuya cloud account, please go to ", "setupPageLink": "Tuya configuration page.", @@ -1126,23 +1127,56 @@ "updates": "Check updates", "editButton": "Edit", "noDeviceFound": "No Tuya device found.", - "featuresLabel": "Features" + "featuresLabel": "Features", + "idLabel": "Device ID", + "localKeyLabel": "Local Key", + "protocolVersionLabel": "Protocol Version", + "protocol35OptionUnsupported": "3.5 (Not supported)", + "protocolVersionRequired": "Protocol 3.5 is not yet supported but will be available soon.", + "ipAddressLabel": "IP Address", + "ipModeLocal": "Local", + "ipModeCloud": "Cloud", + "localInfoHelp": "Use the Cloud/Local button to choose the communication mode. Local mode lets you edit the local IP. Cloud mode shows the cloud IP (read-only).", + "localPollButton": "Poll local DPS", + "localPollInProgress": "Local poll in progress (protocol {{protocol}})...", + "localPollHelp": "If you know the protocol, select it then click the button to verify. If you don't, just click the button for a full scan (it takes longer).", + "localPollRequired": "Local mode is enabled and local settings changed. Run \"Poll local DPS\" successfully before saving. If local mode doesn't work, switch back to cloud mode.", + "localPollSuccess": "Local poll OK.", + "localPollError": "Local poll failed:", + "productIdLabel": "Product ID", + "productKeyLabel": "Product Key" }, "discover": { "title": "Devices detected on your Tuya cloud account", "description": "Tuya devices are automatically discovered. Your Tuya devices need to be added to your Tuya cloud account before.", + "localDiscoveryInfo": "Cloud ScanRefreshes the cloud list. Make sure your devices are set as controllable in Tuya, otherwise they stay read-only. The cloud trial is limited to 10 controllable devices; additional devices may only provide state updates.

Auto local scanRuns a scan on your local network (UDP broadcast, same subnet as Gladys). If a device is not found, fill in its local IP and protocol version (or leave empty if unknown), then use \"Poll local DPS\" to validate local communication.

Use Save to create a device and Update to apply changes or switch the device to local mode.", + "scanCloudInProgress": "Scan in progress... retrieving cloud devices. This can take a while.", + "scanLocalInProgressConnected": "Scan in progress... retrieving local information for devices. This can take a while.", + "scanLocalInProgressDisconnected": "Scan in progress... retrieving local devices. This can take a while.", + "scanCloud": "Cloud Scan", + "localScanAuto": "Auto local scan", + "udpScanError": "Error while running local UDP scan.", + "udpScanPortInUse": "Unable to listen on ports {{ports}} (already in use). Stop services using these ports and run the scan again.", "error": "Error discovering Tuya devices. Please verify your credentials on Setup.", "noDeviceFound": "No Tuya device discovered.", "scan": "Scan" }, "setup": { "title": "Tuya configuration", - "description": "You can connect Gladys to your Tuya cloud account to control the related devices.", - "descriptionCreateAccount": "You need to create an account at Tuya.", - "descriptionCreateProject": "You must then create a \"Cloud Project\" in your Tuya account.", - "descriptionGetKeys": "You will have access to the Access Key and the Secret Key.", - "descriptionGetAppAccountUid": "To have your \"App account UID\", you must go to the \"Devices\" -> \"Link Tuya App Account\" section and add an application account.", - "descriptionGetAppAccountUid2": "Once the addition is finalized, your \"App account UID\" will be in the UID column.", + "cloudTitle": "Cloud", + "description": "You can connect Gladys to your Tuya cloud account to control the related devices. Documentation is available in the left menu or here.", + "descriptionCreateAccount": "You need to create an account at Tuya (Sign up / Log in).", + "descriptionCreateProject": "You must then create a \"Cloud Project\" in your Tuya account (console: Tuya IoT Platform).", + "descriptionGetKeys": "You will have access to both keys: Client ID and Client Secret.", + "descriptionGetAppAccountUid": "To get your App account UID, go to \"Devices\" -> \"Link Tuya App Account\" and add an application account.", + "descriptionGetAppAccountUid2": "Once the account is linked, your App account UID appears in the UID column.", + "descriptionTrial": "Tuya Cloud projects have a trial period: make sure to renew or extend it regularly to avoid service interruptions.", + "descriptionCloudLimit": "Note: the Tuya Cloud trial allows up to 10 controllable devices. Additional devices may stay read-only until you upgrade.", + "descriptionControllable": "Some devices are read-only by default. In Tuya IoT Platform, open Device Permission and click Change to make them controllable.", + "localTitle": "Local", + "descriptionLocalMode": "Local mode can be used after the cloud setup: provide the local IP and protocol version on each device to poll locally. If devices are on the same network as the Gladys machine, the IP discovery and protocol version are detected automatically. This lets you avoid renewing the cloud trial, but the cloud is still needed when you add a new device from the Tuya app.", + "descriptionLocalKeepsApp": "Local control does not disable control from the Tuya/Smart Life app.", + "descriptionCameraLimit": "Cameras: video streaming is not supported in Gladys yet.", "endpoints": { "china": "China", "westernAmerica": "Western America", @@ -1152,17 +1186,30 @@ "india": "India" }, "endpoint": "Endpoint", - "accessKey": "Access Key", - "accessKeyPlaceholder": "Enter Tuya access key", - "secretKey": "Secret Key", - "secretKeyPlaceholder": "Enter Tuya secret key", + "accessKey": "Client ID", + "accessKeyPlaceholder": "Enter Tuya Client ID", + "secretKey": "Client Secret", + "secretKeyPlaceholder": "Enter Tuya Client Secret", "appAccountId": "App account UID", - "appAccountIdPlaceholder": "Enter Tuya application UID", - "saveLabel": "Save configuration", + "appAccountIdPlaceholder": "Enter Tuya app UID", + "appUsername": "Smart Life username (optional)", + "appUsernamePlaceholder": "Smart Life account email or phone", + "saveLabel": "Save configuration and connect", "error": "An error occurred while saving configuration.", "connecting": "Configuration saved. Now connecting to your Tuya cloud account...", - "connected": "Connected to the Tuya cloud account with success !", - "connectionError": "Error while connecting, please check your configuration." + "connectionError": "Error while connecting, please check your configuration.", + "errorInvalidEndpoint": "Endpoint seems incorrect. Please verify your endpoint.", + "errorInvalidClientId": "Client ID seems invalid. Please verify your Client ID.", + "errorInvalidClientSecret": "Client Secret seems invalid. Please verify your Client Secret.", + "errorInvalidAppAccountUid": "App account UID seems invalid. Please verify your App account UID.", + "notConfigured": "Tuya cloud connection details are missing or incomplete.", + "connectedAfterSave": "Connected to the Tuya cloud account with success!", + "connectedStatus": "Connected to the Tuya cloud account.", + "disconnectSuccess": "Disconnected from the Tuya cloud account with success! Local service still works (it will not auto-reconnect when Gladys restarts).", + "disconnectedUnexpected": "You have been disconnected from the Tuya cloud! Please check your credentials and your Tuya IoT Core trial status.", + "disconnectedManual": "Disconnected from the Tuya cloud! Local service still works (it will not auto-reconnect when Gladys restarts).", + "disconnectedMissingConfig": "Disconnected from the Tuya cloud.", + "disconnectLabel": "Disconnect from cloud" }, "error": { "defaultError": "There was an error saving the device.", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index bfa52ce8ab..83f2dd260f 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1353,6 +1353,7 @@ "alreadyCreatedButton": "Déjà créé", "deleteButton": "Supprimer", "unmanagedModelButton": "Modèle non pris en charge ou non disponible", + "localModeLimitInfo": "Le mode local est encore en amélioration. Pour certains appareils, les commandes ou retours d'état peuvent encore passer par le cloud Tuya, même si le mode Local est activé.", "status": { "notConnected": "Gladys n'a pas réussi à se connecter au compte cloud Tuya, plus d'informations sur la ", "setupPageLink": "page de configuration Tuya.", @@ -1364,23 +1365,56 @@ "updates": "Vérifier les mises à jour", "editButton": "Éditer", "noDeviceFound": "Aucun appareil Tuya trouvé.", - "featuresLabel": "Fonctionnalités" + "featuresLabel": "Fonctionnalités", + "idLabel": "Identifiant de l'appareil", + "localKeyLabel": "Clé locale", + "protocolVersionLabel": "Version du protocole", + "protocol35OptionUnsupported": "3.5 (Non supporté)", + "protocolVersionRequired": "Le protocole 3.5 n'est pas encore supporté mais le sera prochainement.", + "ipAddressLabel": "Adresse IP", + "ipModeLocal": "Local", + "ipModeCloud": "Cloud", + "localInfoHelp": "Utilisez le bouton Cloud/Local pour choisir le mode de communication. Le mode local permet de modifier l'IP locale. Le mode cloud affiche l'IP cloud (lecture seule).", + "localPollButton": "Lecture locale des DP", + "localPollInProgress": "Lecture locale en cours... protocole {{protocol}}.", + "localPollHelp": "Si vous connaissez le protocole, sélectionnez-le puis cliquez pour vérifier. Sinon, cliquez directement pour un scan complet (plus long).", + "localPollRequired": "Le mode local est activé et les paramètres locaux ont changé. Lancez une lecture locale des DP avec succès avant de sauvegarder. Si vous n'arrivez pas à faire fonctionner le local, repassez en mode cloud.", + "localPollSuccess": "Lecture locale OK.", + "localPollError": "Échec de la lecture locale :", + "productIdLabel": "Identifiant produit", + "productKeyLabel": "Clé produit" }, "discover": { "title": "Appareils détectés sur votre compte cloud Tuya", "description": "Les appareils Tuya sont automatiquement découverts. Vos appareils Tuya doivent être ajoutés à votre compte cloud Tuya avant.", + "localDiscoveryInfo": "Scan CloudRafraîchit la liste cloud. Assurez-vous que vos appareils sont en mode contrôlable dans Tuya, sinon ils resteront en lecture seule. La période d'essai est limitée à 10 appareils contrôlables : les autres n'auront que le retour d'état.

Scan local autoLance un scan sur votre réseau local (broadcast UDP, même plage IP que Gladys). Si un appareil n'est pas trouvé, renseignez l'IP locale et la version du protocole (ou laissez vide si vous ne la connaissez pas), puis utilisez \"Lecture locale des DP\" pour valider la communication locale.

Utilisez Sauvegarder pour créer un appareil et Mettre à jour pour appliquer des changements ou passer l'appareil en local.", + "scanCloudInProgress": "Scan en cours... récupération cloud des appareils. Cela peut prendre un moment.", + "scanLocalInProgressConnected": "Scan en cours... récupération des informations locales des appareils. Cela peut prendre un moment.", + "scanLocalInProgressDisconnected": "Scan en cours... récupération des appareils en local. Cela peut prendre un moment.", + "scanCloud": "Scan Cloud", + "localScanAuto": "Scan local auto", + "udpScanError": "Erreur lors du scan UDP local.", + "udpScanPortInUse": "Impossible d'écouter sur les ports {{ports}} (déjà utilisés). Fermez les services qui écoutent sur ces ports puis relancez le scan.", "error": "Erreur de découverte des appareils Tuya. Veuillez vérifier vos informations d'identification lors de l'installation.", "noDeviceFound": "Aucun appareil Tuya n'a été découvert.", "scan": "Scanner" }, "setup": { "title": "Configuration Tuya", - "description": "Vous pouvez connecter Gladys à votre compte cloud Tuya pour commander les appareils associés.", - "descriptionCreateAccount": "Vous avez besoin de créer un compte sur Tuya.", - "descriptionCreateProject": "Vous devez ensuite créer un \"Cloud Project\" dans votre compte Tuya.", - "descriptionGetKeys": "Vous aurez accès aux deux clés : Access Key et Secret Key.", - "descriptionGetAppAccountUid": "Pour avoir votre \"App account UID\", il faut vous rendre dans la section \"Devices\" -> \"Link Tuya App Account\" et ajouter un compte d'application.", - "descriptionGetAppAccountUid2": "Une fois que l'ajout est finalisé, votre \"App account UID\" sera dans la colonne UID.", + "cloudTitle": "Cloud", + "description": "Vous pouvez connecter Gladys à votre compte cloud Tuya pour commander les appareils associés. La documentation est disponible dans le menu de gauche ou ici.", + "descriptionCreateAccount": "Vous avez besoin de créer un compte sur Tuya (Créer un compte / Se connecter).", + "descriptionCreateProject": "Vous devez ensuite créer un \"Cloud Project\" dans votre compte Tuya (console : Tuya IoT Platform).", + "descriptionGetKeys": "Vous aurez accès aux deux clés : Client ID et Client Secret.", + "descriptionGetAppAccountUid": "Pour avoir votre App account UID, il faut vous rendre dans la section \"Devices\" -> \"Link Tuya App Account\" et ajouter un compte d'application.", + "descriptionGetAppAccountUid2": "Une fois que l'ajout est finalisé, votre App account UID sera dans la colonne UID.", + "descriptionTrial": "Les projets Tuya Cloud ont une période d'essai : pensez à la renouveler ou l'étendre régulièrement pour éviter les coupures.", + "descriptionCloudLimit": "À noter : l'essai Tuya Cloud permet jusqu'à 10 appareils contrôlables. Les appareils supplémentaires peuvent rester en lecture seule tant que vous n'avez pas mis à niveau.", + "descriptionControllable": "Certains appareils sont en lecture seule par défaut. Dans la Tuya IoT Platform, ouvrez Device Permission puis cliquez sur Change pour les rendre contrôlables.", + "localTitle": "Local", + "descriptionLocalMode": "Le mode local peut être utilisé après la configuration cloud : renseignez l'IP locale et la version du protocole sur chaque appareil pour interroger en local. Si les appareils sont sur le même réseau que la machine Gladys, la découverte IP et la version du protocole sont automatiquement détectées. Ce mode permet de se passer du renouvellement cloud, mais il reste nécessaire lors de l'ajout d'un nouvel appareil depuis l'application Tuya.", + "descriptionLocalKeepsApp": "Le contrôle en local ne désactive pas le contrôle via l'application Tuya/Smart Life.", + "descriptionCameraLimit": "Caméras : le flux vidéo n'est pas encore pris en charge dans Gladys.", "endpoints": { "china": "China", "westernAmerica": "Western America", @@ -1390,17 +1424,30 @@ "india": "India" }, "endpoint": "Endpoint", - "accessKey": "Access Key", - "accessKeyPlaceholder": "Entrez l'Access Key de Tuya", - "secretKey": "Secret Key", - "secretKeyPlaceholder": "Entrez la Secret Key de Tuya", - "appAccountId": "App account UID", + "accessKey": "Client ID", + "accessKeyPlaceholder": "Entrez le 'Client ID' de Tuya", + "secretKey": "Client Secret", + "secretKeyPlaceholder": "Entrez le 'Client Secret' de Tuya", + "appAccountId": "App account User ID", "appAccountIdPlaceholder": "Entrez l'UID de l'app de Tuya", - "saveLabel": "Enregistrer la configuration", + "appUsername": "Identifiant Smart Life (optionnel)", + "appUsernamePlaceholder": "Email ou téléphone du compte Smart Life", + "saveLabel": "Enregistrer la configuration et se connecter", "error": "Une erreur s'est produite lors de la sauvegarde de la configuration.", "connecting": "Configuration sauvegardée. Connexion à votre compte cloud Tuya...", - "connected": "Connexion réussie au compte cloud Tuya !", - "connectionError": "Erreur lors de la connexion, veuillez vérifier votre configuration." + "connectionError": "Erreur lors de la connexion, veuillez vérifier votre configuration.", + "errorInvalidEndpoint": "L'endpoint semble incorrect. Veuillez vérifier l'endpoint.", + "errorInvalidClientId": "Le Client ID semble invalide. Veuillez vérifier le Client ID.", + "errorInvalidClientSecret": "Le Client Secret semble invalide. Veuillez vérifier le Client Secret.", + "errorInvalidAppAccountUid": "L'UID du compte d'application semble invalide. Veuillez vérifier l'UID du compte d'application.", + "notConfigured": "Informations de connexion au compte cloud Tuya non renseignées ou manquantes !", + "connectedAfterSave": "Connexion au compte cloud Tuya réussie !", + "connectedStatus": "Connecté au compte cloud Tuya.", + "disconnectSuccess": "Déconnexion du compte cloud Tuya réussie ! Le service local fonctionne toujours (vous ne serez pas reconnecté automatiquement au redémarrage de Gladys).", + "disconnectedUnexpected": "Vous avez été déconnecté du cloud Tuya ! Vérifiez vos informations de connexion et l'état de votre abonnement d'essai Tuya IoT Core.", + "disconnectedManual": "Déconnecté du cloud Tuya ! Le service local fonctionne toujours (vous ne serez pas reconnecté automatiquement au redémarrage de Gladys).", + "disconnectedMissingConfig": "Déconnecté du cloud Tuya.", + "disconnectLabel": "Se déconnecter du cloud" }, "error": { "defaultError": "Une erreur s'est produite lors de l'enregistrement de l'appareil.", diff --git a/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx b/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx index 7e202253a0..849f2a16f4 100644 --- a/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx +++ b/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx @@ -5,20 +5,192 @@ import { Link } from 'preact-router'; import get from 'get-value'; import DeviceFeatures from '../../../../components/device/view/DeviceFeatures'; import { connect } from 'unistore/preact'; +import { RequestStatus } from '../../../../utils/consts'; + +const normalizeBoolean = value => + value === true || value === 1 || value === '1' || value === 'true' || value === 'TRUE'; +const ONLINE_RECENT_MINUTES = 5; + +const parseDate = dateValue => { + if (!dateValue) { + return null; + } + let date = new Date(dateValue); + if (!Number.isNaN(date.getTime())) { + return date; + } + if (typeof dateValue === 'string') { + const normalized = dateValue.replace(' ', 'T').replace(' +', '+'); + date = new Date(normalized); + } + if (Number.isNaN(date.getTime())) { + return null; + } + return date; +}; + +const getMostRecentFeatureDate = device => { + if (!Array.isArray(device && device.features)) { + return null; + } + return device.features.reduce((mostRecent, feature) => { + const featureDate = parseDate(feature && feature.last_value_changed); + if (!featureDate) { + return mostRecent; + } + if (!mostRecent || featureDate > mostRecent) { + return featureDate; + } + return mostRecent; + }, null); +}; + +const isReachableFromRecentFeatures = device => { + const mostRecentFeatureDate = getMostRecentFeatureDate(device); + if (!mostRecentFeatureDate) { + return false; + } + return Date.now() - mostRecentFeatureDate.getTime() <= ONLINE_RECENT_MINUTES * 60 * 1000; +}; + +const resolveOnlineStatus = device => { + if (isReachableFromRecentFeatures(device)) { + return true; + } + const online = device && device.online; + if (typeof online === 'boolean') { + return online; + } + if (online === 1 || online === 0) { + return online === 1; + } + return false; +}; + +const buildParamsMap = device => + (Array.isArray(device && device.params) ? device.params : []).reduce((acc, param) => { + acc[param.name] = param.value; + return acc; + }, {}); + +const buildComparableDevice = device => { + if (!device) { + return null; + } + const params = buildParamsMap(device); + const localOverrideRaw = + params.LOCAL_OVERRIDE !== undefined && params.LOCAL_OVERRIDE !== null + ? params.LOCAL_OVERRIDE + : device.local_override; + return { + name: device.name || '', + room_id: device.room_id || null, + ip: params.IP_ADDRESS || device.ip || '', + protocol: params.PROTOCOL_VERSION || device.protocol_version || '', + local_override: normalizeBoolean(localOverrideRaw) + }; +}; + +const hasDeviceChanged = (device, baselineDevice) => { + const current = buildComparableDevice(device); + const baseline = buildComparableDevice(baselineDevice); + if (!current || !baseline) { + return false; + } + return ( + current.name !== baseline.name || + current.room_id !== baseline.room_id || + current.ip !== baseline.ip || + current.protocol !== baseline.protocol || + current.local_override !== baseline.local_override + ); +}; + +const getLocalConfig = device => { + if (!device) { + return { + ip: '', + protocol: '', + localOverride: false + }; + } + const params = buildParamsMap(device); + const localOverrideRaw = + params.LOCAL_OVERRIDE !== undefined && params.LOCAL_OVERRIDE !== null + ? params.LOCAL_OVERRIDE + : device.local_override; + return { + ip: params.IP_ADDRESS || device.ip || '', + protocol: params.PROTOCOL_VERSION || device.protocol_version || '', + localOverride: normalizeBoolean(localOverrideRaw) + }; +}; + +const hasLocalConfigChanged = (currentConfig, baselineConfig) => + currentConfig.localOverride !== baselineConfig.localOverride || + currentConfig.ip !== baselineConfig.ip || + currentConfig.protocol !== baselineConfig.protocol; + +const isLocalPollValidated = (validation, currentConfig) => + !!validation && + validation.localOverride === true && + validation.ip === currentConfig.ip && + validation.protocol === currentConfig.protocol; class TuyaDeviceBox extends Component { componentWillMount() { this.setState({ - device: this.props.device + device: this.props.device, + baselineDevice: this.props.device, + localPollValidation: null }); } componentWillReceiveProps(nextProps) { + const currentDevice = this.state.device; + const nextDevice = nextProps.device; + const isNewDevice = !currentDevice || currentDevice.external_id !== nextDevice.external_id; + const baselineDevice = this.state.baselineDevice; + const shouldRefreshBaseline = isNewDevice || !baselineDevice || baselineDevice.updated_at !== nextDevice.updated_at; + if (isNewDevice) { + this.setState({ + device: nextDevice, + baselineDevice: nextDevice, + localPollValidation: null + }); + return; + } this.setState({ - device: nextProps.device + device: nextDevice, + baselineDevice: shouldRefreshBaseline ? nextDevice : baselineDevice }); } + toggleIpMode = () => { + const device = this.state.device; + const params = Array.isArray(device.params) ? [...device.params] : []; + const overrideParam = params.find(param => param.name === 'LOCAL_OVERRIDE'); + const localOverrideRaw = overrideParam ? overrideParam.value : device.local_override; + const currentOverride = normalizeBoolean(localOverrideRaw); + const nextOverride = currentOverride !== true; + const existingIndex = params.findIndex(param => param.name === 'LOCAL_OVERRIDE'); + if (existingIndex >= 0) { + params[existingIndex] = { ...params[existingIndex], value: nextOverride }; + } else { + params.push({ name: 'LOCAL_OVERRIDE', value: nextOverride }); + } + this.setState({ + device: { + ...device, + params, + local_override: nextOverride + }, + localPollValidation: null, + localPollStatus: null, + localPollError: null + }); + }; + updateName = e => { this.setState({ device: { @@ -37,6 +209,132 @@ class TuyaDeviceBox extends Component { }); }; + updateProtocol = e => { + const protocolVersion = e.target.value; + if (protocolVersion === '3.5') { + return; + } + const params = Array.isArray(this.state.device.params) ? [...this.state.device.params] : []; + const existingIndex = params.findIndex(param => param.name === 'PROTOCOL_VERSION'); + if (existingIndex >= 0) { + params[existingIndex] = { ...params[existingIndex], value: protocolVersion }; + } else { + params.push({ name: 'PROTOCOL_VERSION', value: protocolVersion }); + } + this.setState({ + device: { + ...this.state.device, + params + }, + localPollValidation: null, + localPollStatus: null, + localPollError: null + }); + }; + + pollLocal = async () => { + this.setState({ + localPollStatus: RequestStatus.Getting, + localPollError: null, + localPollProtocol: null + }); + const params = Array.isArray(this.state.device.params) ? this.state.device.params : []; + const getParam = name => { + const found = params.find(param => param.name === name); + return found ? found.value : undefined; + }; + const tryProtocols = ['3.4', '3.3', '3.1']; + const selectedProtocol = getParam('PROTOCOL_VERSION') || this.state.device.protocol_version; + const protocolList = selectedProtocol ? [selectedProtocol] : tryProtocols; + try { + let result = null; + let usedProtocol = selectedProtocol; + let latestDevice = null; + const isValidResult = data => data && typeof data === 'object' && data.dps; + for (let i = 0; i < protocolList.length; i += 1) { + const protocolVersion = protocolList[i]; + try { + this.setState({ + localPollProtocol: protocolVersion + }); + const response = await this.props.httpClient.post('/api/v1/service/tuya/local-poll', { + deviceId: this.state.device.external_id && this.state.device.external_id.split(':')[1], + ip: getParam('IP_ADDRESS') || this.state.device.ip, + localKey: getParam('LOCAL_KEY') || this.state.device.local_key, + protocolVersion, + timeoutMs: 3000, + fastScan: true + }); + result = response && response.dps ? response : null; + const updatedDevice = response && response.device ? response.device : null; + if (updatedDevice) { + latestDevice = updatedDevice; + } + if (!isValidResult(result)) { + throw new Error('Invalid local poll response'); + } + usedProtocol = protocolVersion; + break; + } catch (e) { + if (i === protocolList.length - 1) { + throw e; + } + } + } + const newParams = [...params]; + if (usedProtocol) { + const protocolIndex = newParams.findIndex(param => param.name === 'PROTOCOL_VERSION'); + if (protocolIndex >= 0) { + newParams[protocolIndex] = { ...newParams[protocolIndex], value: usedProtocol }; + } else { + newParams.push({ name: 'PROTOCOL_VERSION', value: usedProtocol }); + } + } + const baseDevice = latestDevice || this.state.device; + this.setState({ + device: { + ...baseDevice, + params: newParams + }, + localPollStatus: RequestStatus.Success, + localPollProtocol: null, + localPollValidation: { + ip: getParam('IP_ADDRESS') || this.state.device.ip || '', + protocol: usedProtocol || '', + localOverride: true + } + }); + } catch (e) { + const message = + (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || 'Unknown error'; + this.setState({ + localPollStatus: RequestStatus.Error, + localPollError: message, + localPollProtocol: null + }); + } + }; + + updateIpAddress = e => { + const ipAddress = e.target.value; + const params = Array.isArray(this.state.device.params) ? [...this.state.device.params] : []; + const existingIndex = params.findIndex(param => param.name === 'IP_ADDRESS'); + if (existingIndex >= 0) { + params[existingIndex] = { ...params[existingIndex], value: ipAddress }; + } else { + params.push({ name: 'IP_ADDRESS', value: ipAddress }); + } + this.setState({ + device: { + ...this.state.device, + params + }, + localPollValidation: null, + localPollStatus: null, + localPollError: null + }); + }; + saveDevice = async () => { this.setState({ loading: true, @@ -45,11 +343,15 @@ class TuyaDeviceBox extends Component { try { const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device); this.setState({ - device: savedDevice + device: savedDevice, + baselineDevice: savedDevice }); + if (typeof this.props.onDeviceSaved === 'function') { + this.props.onDeviceSaved(savedDevice); + } } catch (e) { let errorMessage = 'integration.tuya.error.defaultError'; - if (e.response.status === 409) { + if (e.response && e.response.status === 409) { errorMessage = 'integration.tuya.error.conflictError'; } this.setState({ @@ -101,10 +403,54 @@ class TuyaDeviceBox extends Component { alreadyCreatedButton, housesWithRooms }, - { device, loading, errorMessage, tooMuchStatesError, statesNumber } + { + device, + loading, + errorMessage, + tooMuchStatesError, + statesNumber, + localPollStatus, + localPollError, + localPollProtocol, + localPollValidation + } ) { const validModel = device.features && device.features.length > 0; - const online = device.online; + const online = resolveOnlineStatus(device); + const paramsArray = Array.isArray(device.params) ? device.params : []; + const params = paramsArray.reduce((acc, param) => { + acc[param.name] = param.value; + return acc; + }, {}); + const deviceId = params.DEVICE_ID || (device.external_id ? device.external_id.split(':')[1] : ''); + const localKey = params.LOCAL_KEY || device.local_key || ''; + const productId = params.PRODUCT_ID || device.product_id || ''; + const productKey = params.PRODUCT_KEY || device.product_key || ''; + const protocolVersion = params.PROTOCOL_VERSION || device.protocol_version || ''; + const localOverrideRaw = + params.LOCAL_OVERRIDE !== undefined && params.LOCAL_OVERRIDE !== null + ? params.LOCAL_OVERRIDE + : device.local_override; + const localOverride = normalizeBoolean(localOverrideRaw); + const ipAddress = params.IP_ADDRESS || device.ip || ''; + const cloudIp = params.CLOUD_IP || device.cloud_ip || ''; + const showCloudIp = localOverride !== true; + const displayIp = showCloudIp ? cloudIp : ipAddress; + const isValidIp = + typeof ipAddress === 'string' && /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(ipAddress); + const canPollLocal = localOverride === true && isValidIp && localKey; + const hasLocalChanges = hasDeviceChanged(device, this.state.baselineDevice); + const currentLocalConfig = getLocalConfig(device); + const baselineLocalConfig = getLocalConfig(this.state.baselineDevice); + const localConfigChanged = hasLocalConfigChanged(currentLocalConfig, baselineLocalConfig); + const requiresLocalPollValidation = currentLocalConfig.localOverride === true && localConfigChanged; + const localPollValidated = isLocalPollValidated(localPollValidation, currentLocalConfig); + const canSave = !requiresLocalPollValidation || localPollValidated; + const isDiscoverPage = !deleteButton; + const showUpdateButton = + validModel && isDiscoverPage && (updateButton || (alreadyCreatedButton && hasLocalChanges)); + const showAlreadyCreatedButton = validModel && alreadyCreatedButton && !hasLocalChanges; + const pollProtocolLabel = localPollProtocol || protocolVersion || '-'; return (
@@ -147,7 +493,7 @@ class TuyaDeviceBox extends Component { onInput={this.updateName} class="form-control" placeholder={} - disabled={!editable || !validModel} + disabled={!editable} />
@@ -165,6 +511,141 @@ class TuyaDeviceBox extends Component { /> +
+ + +
+ +
+ + +
+ + {productKey && ( +
+ + +
+ )} + +
+ + +
+ +
+ +
+ +
+ +
+
+ + + +
+ +
+ + + {!showCloudIp && (!protocolVersion || protocolVersion === '3.5') && ( +
+ +
+ )} +
+ +
+ + {localPollStatus === RequestStatus.Getting && ( + + + )} + {localPollStatus === RequestStatus.Success && ( + + + + )} + {localPollStatus === RequestStatus.Error && ( + + {localPollError} + + )} + + + +
+
+
+ +
{ + const isCreated = !!device.created_at; + const hasFeatures = device.features && device.features.length > 0; + const isUpdatable = !!device.updatable; + if (!isCreated && hasFeatures) { + return 0; + } + if (isCreated && isUpdatable) { + return 1; + } + if (!isCreated && !hasFeatures) { + return 2; + } + if (isCreated && !isUpdatable) { + return 3; + } + return 4; +}; + +const sortDevices = devices => + [...devices].sort((a, b) => { + const rankDiff = getDeviceRank(a) - getDeviceRank(b); + if (rankDiff !== 0) { + return rankDiff; + } + const nameA = (a.name || '').toLowerCase(); + const nameB = (b.name || '').toLowerCase(); + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + return 0; + }); + class DiscoverTab extends Component { async componentWillMount() { this.getDiscoveredDevices(); @@ -54,7 +90,77 @@ class DiscoverTab extends Component { } }; - render(props, { loading, errorLoading, discoveredDevices, housesWithRooms }) { + runLocalScan = async () => { + this.setState({ + udpScanLoading: true, + udpScanError: false, + udpScanPortErrors: null + }); + try { + const response = await this.props.httpClient.post('/api/v1/service/tuya/local-scan', { + timeoutSeconds: 10 + }); + if (response && response.devices) { + this.setState({ + discoveredDevices: response.devices + }); + } else { + await this.getDiscoveredDevices(); + } + this.setState({ + udpScanLoading: false, + udpScanPortErrors: response && response.port_errors ? response.port_errors : null + }); + } catch (e) { + this.setState({ + udpScanLoading: false, + udpScanError: true + }); + } + }; + + handleDeviceSaved = savedDevice => { + if (!savedDevice || !savedDevice.external_id) { + this.getDiscoveredDevices(); + return; + } + this.setState(prevState => { + const { discoveredDevices } = prevState; + if (!Array.isArray(discoveredDevices)) { + return null; + } + return { + discoveredDevices: discoveredDevices.map(device => { + if (device.external_id !== savedDevice.external_id) { + return device; + } + return { + ...device, + ...savedDevice, + updatable: false + }; + }) + }; + }); + }; + + render( + props, + { loading, errorLoading, discoveredDevices, housesWithRooms, udpScanLoading, udpScanError, udpScanPortErrors } + ) { + const isLoading = loading || udpScanLoading; + const canScanCloud = !isLoading; + const localScanTextId = errorLoading + ? 'integration.tuya.discover.scanLocalInProgressDisconnected' + : 'integration.tuya.discover.scanLocalInProgressConnected'; + const scanTextId = udpScanLoading + ? localScanTextId + : loading + ? 'integration.tuya.discover.scanCloudInProgress' + : null; + const portErrorPorts = udpScanPortErrors ? Object.keys(udpScanPortErrors) : []; + const orderedDevices = Array.isArray(discoveredDevices) ? sortDevices(discoveredDevices) : []; + return (
@@ -62,21 +168,54 @@ class DiscoverTab extends Component {
- +
+
+ +
+
+ +
+ {udpScanError && ( +
+ +
+ )} + {isLoading && scanTextId && ( +
+
+ +
+ )} + {portErrorPorts.length > 0 && ( +
+ + + + + +
+ )}
-
{errorLoading && (

@@ -87,19 +226,20 @@ class DiscoverTab extends Component {

)}
- {discoveredDevices && - discoveredDevices.map((device, index) => ( - - ))} - {!discoveredDevices || (discoveredDevices.length === 0 && )} + {orderedDevices.map((device, index) => ( + + ))} + {orderedDevices.length === 0 && }
diff --git a/front/src/routes/integration/all/tuya/discover-page/style.css b/front/src/routes/integration/all/tuya/discover-page/style.css index 0ce9311518..b226dd9f9e 100644 --- a/front/src/routes/integration/all/tuya/discover-page/style.css +++ b/front/src/routes/integration/all/tuya/discover-page/style.css @@ -5,3 +5,9 @@ .tuyaListBody { min-height: 200px; } + +.scanLoader { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx b/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx index b83ae99277..1d776faf46 100644 --- a/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx +++ b/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx @@ -4,10 +4,38 @@ import cx from 'classnames'; import { RequestStatus } from '../../../../../utils/consts'; import { Component } from 'preact'; import { connect } from 'unistore/preact'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; class SetupTab extends Component { - componentWillMount() { + constructor(props) { + super(props); + this.state = { + tuyaConnectionStatus: null, + tuyaConnectionError: null, + tuyaConnected: false, + tuyaConnecting: false, + tuyaConfigured: false, + tuyaDisconnected: false, + tuyaManuallyDisconnected: false, + tuyaManualDisconnectJustDone: false, + tuyaJustSaved: false, + tuyaJustSavedMissing: false, + tuyaDisconnecting: false, + tuyaStatusLoading: false, + showClientSecret: false + }; + } + + componentDidMount() { this.getTuyaConfiguration(); + this.getTuyaStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, this.updateConnectionStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR, this.displayConnectionError); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, this.updateConnectionStatus); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR, this.displayConnectionError); } async getTuyaConfiguration() { @@ -15,84 +43,286 @@ class SetupTab extends Component { let tuyaAccessKey = ''; let tuyaSecretKey = ''; let tuyaAppAccountId = ''; + let tuyaAppUsername = ''; this.setState({ tuyaGetSettingsStatus: RequestStatus.Getting, tuyaEndpoint, tuyaAccessKey, tuyaSecretKey, - tuyaAppAccountId + tuyaAppAccountId, + tuyaAppUsername }); - try { - const { value: endpoint } = await this.props.httpClient.get('/api/v1/service/tuya/variable/TUYA_ENDPOINT'); - tuyaEndpoint = endpoint; - - const { value: accessKey } = await this.props.httpClient.get('/api/v1/service/tuya/variable/TUYA_ACCESS_KEY'); - tuyaAccessKey = accessKey; + const getVariable = async (name, fallback = '') => { + try { + const response = await this.props.httpClient.get(`/api/v1/service/tuya/variable/${name}`); + return response && response.value ? response.value : fallback; + } catch (e) { + if (e && e.response && e.response.status === 404) { + return fallback; + } + throw e; + } + }; - const { value: secretKey } = await this.props.httpClient.get('/api/v1/service/tuya/variable/TUYA_SECRET_KEY'); - tuyaSecretKey = secretKey; - - const { value: appAccountId } = await this.props.httpClient.get( - '/api/v1/service/tuya/variable/TUYA_APP_ACCOUNT_UID' - ); - tuyaAppAccountId = appAccountId; + try { + [tuyaEndpoint, tuyaAccessKey, tuyaSecretKey, tuyaAppAccountId, tuyaAppUsername] = await Promise.all([ + getVariable('TUYA_ENDPOINT'), + getVariable('TUYA_ACCESS_KEY'), + getVariable('TUYA_SECRET_KEY'), + getVariable('TUYA_APP_ACCOUNT_UID'), + getVariable('TUYA_APP_USERNAME') + ]); this.setState({ tuyaGetSettingsStatus: RequestStatus.Success, tuyaEndpoint, tuyaAccessKey, tuyaSecretKey, - tuyaAppAccountId + tuyaAppAccountId, + tuyaAppUsername, + tuyaConfigured: !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId) }); } catch (e) { this.setState({ - tuyaGetSettingsStatus: RequestStatus.Error + tuyaGetSettingsStatus: RequestStatus.Error, + tuyaEndpoint, + tuyaAccessKey, + tuyaSecretKey, + tuyaAppAccountId, + tuyaAppUsername, + tuyaConfigured: !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId) }); } } - saveTuyaConfiguration = async e => { - e.preventDefault(); + async getTuyaStatus() { this.setState({ - tuyaSaveSettingsStatus: RequestStatus.Getting + tuyaStatusLoading: true }); try { - await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_ENDPOINT', { - value: this.state.tuyaEndpoint - }); + const response = await this.props.httpClient.get('/api/v1/service/tuya/status'); + const status = response && response.status; + const configured = response && response.configured; + const manualDisconnect = response && response.manual_disconnect; + const isConnected = status === 'connected'; + const isConnecting = status === 'connecting'; + const isError = status === 'error'; + const isManualDisconnect = !!manualDisconnect; + const isUnexpectedDisconnect = isError && configured && !manualDisconnect; - await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_ACCESS_KEY', { - value: this.state.tuyaAccessKey.trim() + this.setState({ + tuyaStatusLoading: false, + tuyaConfigured: !!configured, + tuyaConnected: isManualDisconnect ? false : isConnected, + tuyaConnecting: isManualDisconnect ? false : isConnecting, + tuyaDisconnected: isUnexpectedDisconnect, + tuyaManuallyDisconnected: isManualDisconnect, + tuyaManualDisconnectJustDone: false, + tuyaJustSaved: false, + tuyaJustSavedMissing: false, + tuyaConnectionStatus: isManualDisconnect ? null : isError ? RequestStatus.Error : null, + tuyaConnectionError: isManualDisconnect ? null : isError ? response.error : null }); - - await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_SECRET_KEY', { - value: this.state.tuyaSecretKey.trim() + } catch (e) { + this.setState({ + tuyaStatusLoading: false }); + } + } - await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_APP_ACCOUNT_UID', { - value: this.state.tuyaAppAccountId.trim() + saveTuyaConfiguration = async e => { + e.preventDefault(); + const tuyaEndpoint = (this.state.tuyaEndpoint || '').trim(); + const tuyaAccessKey = (this.state.tuyaAccessKey || '').trim(); + const tuyaSecretKey = (this.state.tuyaSecretKey || '').trim(); + const tuyaAppAccountId = (this.state.tuyaAppAccountId || '').trim(); + const tuyaAppUsername = (this.state.tuyaAppUsername || '').trim(); + + this.setState({ + tuyaSaveSettingsStatus: RequestStatus.Getting, + tuyaConnectionStatus: null, + tuyaConnectionError: null, + tuyaConnected: false, + tuyaConnecting: false, + tuyaDisconnected: false, + tuyaManuallyDisconnected: false, + tuyaManualDisconnectJustDone: false, + tuyaJustSaved: true, + tuyaJustSavedMissing: false + }); + try { + await this.props.httpClient.post('/api/v1/service/tuya/configuration', { + endpoint: tuyaEndpoint, + accessKey: tuyaAccessKey, + secretKey: tuyaSecretKey, + appAccountId: tuyaAppAccountId, + appUsername: tuyaAppUsername }); + const configured = !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId); + if (!configured) { + this.setState({ + tuyaSaveSettingsStatus: RequestStatus.Success, + tuyaConfigured: false, + tuyaDisconnected: true, + tuyaJustSavedMissing: true, + tuyaJustSaved: false + }); + return; + } + // start service - await this.props.httpClient.post('/api/v1/service/tuya/start'); + const service = await this.props.httpClient.post('/api/v1/service/tuya/start'); + if (service && service.status === 'ERROR') { + throw new Error('TUYA_START_ERROR'); + } this.setState({ - tuyaSaveSettingsStatus: RequestStatus.Success + tuyaSaveSettingsStatus: RequestStatus.Success, + tuyaConfigured: true }); } catch (e) { + const responseMessage = + (e && e.response && e.response.data && e.response.data.message) || + (e && e.message && e.message !== 'TUYA_START_ERROR' ? e.message : null); this.setState({ - tuyaSaveSettingsStatus: RequestStatus.Error + tuyaSaveSettingsStatus: RequestStatus.Error, + tuyaConnectionError: responseMessage, + tuyaJustSaved: false, + tuyaJustSavedMissing: false }); } }; + updateConnectionStatus = event => { + const status = event && event.status; + const error = event && event.error; + const manualDisconnect = event && event.manual_disconnect; + if (status === 'connecting') { + this.setState({ + tuyaConnectionStatus: RequestStatus.Success, + tuyaConnecting: true, + tuyaConnected: false, + tuyaDisconnected: false, + tuyaManuallyDisconnected: false, + tuyaManualDisconnectJustDone: false, + tuyaJustSavedMissing: false + }); + return; + } + if (status === 'connected') { + this.setState({ + tuyaConnectionStatus: RequestStatus.Success, + tuyaConnectionError: null, + tuyaConnecting: false, + tuyaConnected: true, + tuyaDisconnected: false, + tuyaManuallyDisconnected: false, + tuyaManualDisconnectJustDone: false, + tuyaJustSavedMissing: false + }); + return; + } + if (status === 'error') { + this.setState({ + tuyaConnectionStatus: RequestStatus.Error, + tuyaConnecting: false, + tuyaConnected: false, + tuyaDisconnected: this.state.tuyaConfigured && !manualDisconnect, + tuyaManuallyDisconnected: false, + tuyaManualDisconnectJustDone: false, + tuyaJustSavedMissing: false, + tuyaConnectionError: error || this.state.tuyaConnectionError + }); + return; + } + if (status === 'not_initialized') { + this.setState({ + tuyaConnectionStatus: null, + tuyaConnecting: false, + tuyaConnected: false, + tuyaDisconnected: !manualDisconnect, + tuyaManuallyDisconnected: !!manualDisconnect, + tuyaManualDisconnectJustDone: manualDisconnect ? this.state.tuyaManualDisconnectJustDone : false, + tuyaJustSavedMissing: false + }); + } + }; + + displayConnectionError = error => { + const message = (error && error.message) || (error && error.payload && error.payload.message); + this.setState({ + tuyaConnectionStatus: RequestStatus.Error, + tuyaConnectionError: message || 'unknown', + tuyaConnecting: false, + tuyaConnected: false + }); + }; + + renderTuyaError = error => { + if (!error) { + return null; + } + if (typeof error === 'string' && error.startsWith('integration.tuya.setup.')) { + return ; + } + return {error}; + }; + updateConfiguration = e => { + const { name, value } = e.target; + this.setState(prevState => { + const nextState = { ...prevState, [name]: value }; + const tuyaEndpoint = (nextState.tuyaEndpoint || '').trim(); + const tuyaAccessKey = (nextState.tuyaAccessKey || '').trim(); + const tuyaSecretKey = (nextState.tuyaSecretKey || '').trim(); + const tuyaAppAccountId = (nextState.tuyaAppAccountId || '').trim(); + const configured = !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId); + return { + [name]: value, + tuyaConfigured: configured + }; + }); + }; + + toggleClientSecret = () => { + this.setState({ + showClientSecret: !this.state.showClientSecret + }); + }; + + disconnectFromCloud = async () => { this.setState({ - [e.target.name]: e.target.value + tuyaDisconnecting: true, + tuyaConnectionError: null }); + try { + await this.props.httpClient.post('/api/v1/service/tuya/disconnect'); + this.setState({ + tuyaDisconnecting: false, + tuyaConnected: false, + tuyaConnecting: false, + tuyaDisconnected: false, + tuyaManuallyDisconnected: true, + tuyaManualDisconnectJustDone: true, + tuyaConnectionStatus: null + }); + } catch (e) { + const responseMessage = + (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || 'unknown'; + this.setState({ + tuyaDisconnecting: false, + tuyaConnectionStatus: RequestStatus.Error, + tuyaConnectionError: responseMessage + }); + } }; render(props, state) { + const showUnexpectedDisconnect = state.tuyaDisconnected && state.tuyaConfigured; + const showConnectionError = state.tuyaConnectionStatus === RequestStatus.Error; + const showCombinedDisconnectError = showUnexpectedDisconnect && showConnectionError; + return (
@@ -108,14 +338,103 @@ class SetupTab extends Component { >
-

+ {state.tuyaSaveSettingsStatus === RequestStatus.Error && ( +

+ + {state.tuyaConnectionError && !state.tuyaConnecting && !state.tuyaConnected && ( +
{this.renderTuyaError(state.tuyaConnectionError)}
+ )} +
+ )} + {!state.tuyaConfigured && !state.tuyaStatusLoading && ( +

+ +

+ )} + {state.tuyaJustSavedMissing && ( +

+ +

+ )} + {state.tuyaConnecting && ( +

+ +

+ )} + {state.tuyaConnected && ( +

+ +

+ )} + {state.tuyaManuallyDisconnected && ( +

+ +

+ )} + {showCombinedDisconnectError && ( +
+ +
+ +
+ {state.tuyaConnectionError && ( +
{this.renderTuyaError(state.tuyaConnectionError)}
+ )} +
+ )} + {showUnexpectedDisconnect && !showConnectionError && ( +

+ +

+ )} + {showConnectionError && !showUnexpectedDisconnect && ( +
+ + {state.tuyaConnectionError && ( +
{this.renderTuyaError(state.tuyaConnectionError)}
+ )} +
+ )} +
+ +
+
+ + + +
-

+ + + +
+ + + +
+ + + +
+ +
@@ -169,12 +488,39 @@ class SetupTab extends Component { +
+ + } + value={state.tuyaSecretKey} + className="form-control" + autocomplete="off" + onInput={this.updateConfiguration} + /> + + + + +
+
+ +
+ } - value={state.tuyaSecretKey} + placeholder={} + value={state.tuyaAppAccountId} className="form-control" onInput={this.updateConfiguration} /> @@ -182,15 +528,15 @@ class SetupTab extends Component {
-
+
+ +
@@ -213,4 +569,4 @@ class SetupTab extends Component { } } -export default connect('httpClient', {})(SetupTab); +export default connect('httpClient,session', {})(SetupTab); diff --git a/server/services/tuya/api/tuya.controller.js b/server/services/tuya/api/tuya.controller.js index 439fd52ac4..bebc8905b7 100644 --- a/server/services/tuya/api/tuya.controller.js +++ b/server/services/tuya/api/tuya.controller.js @@ -1,4 +1,7 @@ const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); +const logger = require('../../../utils/logger'); +const { updateDiscoveredDeviceAfterLocalPoll } = require('../lib/tuya.localPoll'); +const { buildLocalScanResponse } = require('../lib/tuya.localScan'); module.exports = function TuyaController(tuyaManager) { /** @@ -11,10 +14,95 @@ module.exports = function TuyaController(tuyaManager) { res.json(devices); } + /** + * @api {post} /api/v1/service/tuya/local-poll Poll one Tuya device locally to retrieve DPS. + * @apiName localPoll + * @apiGroup Tuya + */ + async function localPoll(req, res) { + const payload = req.body || {}; + const result = await tuyaManager.localPoll(payload); + const updatedDevice = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, payload); + + if (updatedDevice) { + res.json({ + ...result, + device: updatedDevice, + }); + return; + } + + res.json(result); + } + + /** + * @api {post} /api/v1/service/tuya/local-scan Manual UDP scan for local Tuya devices. + * @apiName localScan + * @apiGroup Tuya + */ + async function localScan(req, res) { + const { timeoutSeconds } = req.body || {}; + logger.info(`[Tuya][localScan] API request received (timeoutSeconds=${timeoutSeconds || 10})`); + const localScanResult = await tuyaManager.localScan({ + timeoutSeconds, + }); + res.json(buildLocalScanResponse(tuyaManager, localScanResult)); + } + + /** + * @api {get} /api/v1/service/tuya/status Get Tuya connection status. + * @apiName status + * @apiGroup Tuya + */ + async function status(req, res) { + const response = await tuyaManager.getStatus(); + res.json(response); + } + + /** + * @api {post} /api/v1/service/tuya/configuration Save Tuya configuration. + * @apiName saveConfiguration + * @apiGroup Tuya + */ + async function saveConfiguration(req, res) { + const configuration = await tuyaManager.saveConfiguration(req.body); + res.json(configuration); + } + + /** + * @api {post} /api/v1/service/tuya/disconnect Disconnect Tuya cloud. + * @apiName disconnect + * @apiGroup Tuya + */ + async function disconnect(req, res) { + await tuyaManager.manualDisconnect(); + res.json({ success: true }); + } + return { 'get /api/v1/service/tuya/discover': { authenticated: true, controller: asyncMiddleware(discover), }, + 'post /api/v1/service/tuya/local-poll': { + authenticated: true, + controller: asyncMiddleware(localPoll), + }, + 'post /api/v1/service/tuya/local-scan': { + authenticated: true, + controller: asyncMiddleware(localScan), + }, + 'get /api/v1/service/tuya/status': { + authenticated: true, + controller: asyncMiddleware(status), + }, + 'post /api/v1/service/tuya/configuration': { + authenticated: true, + controller: asyncMiddleware(saveConfiguration), + }, + 'post /api/v1/service/tuya/disconnect': { + authenticated: true, + controller: asyncMiddleware(disconnect), + }, }; }; diff --git a/server/services/tuya/index.js b/server/services/tuya/index.js index 712f159d3b..ee5f47f3eb 100644 --- a/server/services/tuya/index.js +++ b/server/services/tuya/index.js @@ -16,7 +16,6 @@ module.exports = function TuyaService(gladys, serviceId) { async function start() { logger.info('Starting Tuya service', serviceId); await tuyaHandler.init(); - await tuyaHandler.loadDevices(); } /** diff --git a/server/services/tuya/lib/device/tuya.convertDevice.js b/server/services/tuya/lib/device/tuya.convertDevice.js index e0ca57440a..458ec919f2 100644 --- a/server/services/tuya/lib/device/tuya.convertDevice.js +++ b/server/services/tuya/lib/device/tuya.convertDevice.js @@ -1,20 +1,77 @@ const { DEVICE_POLL_FREQUENCIES } = require('../../../../utils/constants'); +const { addSelector } = require('../../../../utils/addSelector'); +const { DEVICE_PARAM_NAME } = require('../utils/tuya.constants'); +const { normalizeBoolean } = require('../utils/tuya.normalize'); const { convertFeature } = require('./tuya.convertFeature'); const logger = require('../../../../utils/logger'); /** * @description Transform Tuya device to Gladys device. * @param {object} tuyaDevice - Tuya device. - * @returns {object} Glladys device. + * @returns {object} Gladys device. * @example * tuya.convertDevice({ ... }); */ function convertDevice(tuyaDevice) { - const { name, product_name: model, id, specifications = {} } = tuyaDevice; + const { + name, + product_name: productName, + model, + product_id: productId, + product_key: productKey, + id, + local_key: localKey, + ip, + cloud_ip: cloudIp, + protocol_version: protocolVersion, + local_override: localOverride, + properties, + thing_model: thingModel, + specifications = {}, + } = tuyaDevice; const externalId = `tuya:${id}`; const { functions = [], status = [] } = specifications; + const online = tuyaDevice.online !== undefined ? tuyaDevice.online : tuyaDevice.is_online; + const normalizedLocalOverride = normalizeBoolean(localOverride); + + const params = []; + if (id) { + params.push({ name: DEVICE_PARAM_NAME.DEVICE_ID, value: id }); + } + if (localKey) { + params.push({ name: DEVICE_PARAM_NAME.LOCAL_KEY, value: localKey }); + } + if (ip) { + params.push({ name: DEVICE_PARAM_NAME.IP_ADDRESS, value: ip }); + } + if (cloudIp) { + params.push({ name: DEVICE_PARAM_NAME.CLOUD_IP, value: cloudIp }); + } + if (localOverride !== undefined && localOverride !== null) { + params.push({ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: normalizedLocalOverride }); + } + if (protocolVersion) { + params.push({ name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: protocolVersion }); + } + if (productId) { + params.push({ name: DEVICE_PARAM_NAME.PRODUCT_ID, value: productId }); + } + if (productKey) { + params.push({ name: DEVICE_PARAM_NAME.PRODUCT_KEY, value: productKey }); + } + const safeDeviceLog = { + id, + name, + model: productName || model, + product_id: productId, + protocol_version: protocolVersion, + local_override: normalizedLocalOverride, + online, + }; + logger.debug('Tuya convert device specifications'); + logger.debug(JSON.stringify(safeDeviceLog)); - logger.debug(`Tuya convert device"${name}, ${model}"`); + logger.debug(`Tuya convert device "${name}, ${productName || model}"`); // Groups functions and status on same code const groups = {}; status.forEach((stat) => { @@ -33,11 +90,21 @@ function convertDevice(tuyaDevice) { features: features.filter((feature) => feature), external_id: externalId, selector: externalId, - model, + model: productName || model, + product_id: productId, + product_key: productKey, service_id: this.serviceId, poll_frequency: DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS, should_poll: true, + params, + properties, + specifications, + thing_model: thingModel, }; + if (online !== undefined) { + device.online = online; + } + addSelector(device); return device; } diff --git a/server/services/tuya/lib/device/tuya.convertFeature.js b/server/services/tuya/lib/device/tuya.convertFeature.js index c882a5653e..e2dd4d09b9 100644 --- a/server/services/tuya/lib/device/tuya.convertFeature.js +++ b/server/services/tuya/lib/device/tuya.convertFeature.js @@ -1,4 +1,5 @@ const logger = require('../../../../utils/logger'); +const { addSelector } = require('../../../../utils/addSelector'); const { mappings } = require('./tuya.deviceMapping'); /** @@ -28,7 +29,7 @@ function convertFeature(tuyaFunctions, externalId) { } const feature = { - name, + name: code || name, external_id: `${externalId}:${code}`, selector: `${externalId}:${code}`, read_only: readOnly, @@ -44,6 +45,7 @@ function convertFeature(tuyaFunctions, externalId) { feature.max = valuesObject.max; } + addSelector(feature); return feature; } diff --git a/server/services/tuya/lib/device/tuya.deviceMapping.js b/server/services/tuya/lib/device/tuya.deviceMapping.js index 09ea472700..7ee5203cf9 100644 --- a/server/services/tuya/lib/device/tuya.deviceMapping.js +++ b/server/services/tuya/lib/device/tuya.deviceMapping.js @@ -4,6 +4,7 @@ const { DEVICE_FEATURE_UNITS, COVER_STATE, } = require('../../../../utils/constants'); +const { normalizeBoolean } = require('../utils/tuya.normalize'); const { intToRgb, rgbToHsb, rgbToInt, hsbToRgb } = require('../../../../utils/colors'); @@ -146,7 +147,7 @@ const writeValues = { const readValues = { [DEVICE_FEATURE_CATEGORIES.LIGHT]: { [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: (valueFromDevice) => { - return valueFromDevice === true ? 1 : 0; + return normalizeBoolean(valueFromDevice) ? 1 : 0; }, [DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS]: (valueFromDevice) => { return valueFromDevice; @@ -164,7 +165,7 @@ const readValues = { [DEVICE_FEATURE_CATEGORIES.SWITCH]: { [DEVICE_FEATURE_TYPES.SWITCH.BINARY]: (valueFromDevice) => { - return valueFromDevice === true ? 1 : 0; + return normalizeBoolean(valueFromDevice) ? 1 : 0; }, [DEVICE_FEATURE_TYPES.SWITCH.ENERGY]: (valueFromDevice) => { return parseInt(valueFromDevice, 10) / 100; diff --git a/server/services/tuya/lib/index.js b/server/services/tuya/lib/index.js index baff4797c5..63245a7be0 100644 --- a/server/services/tuya/lib/index.js +++ b/server/services/tuya/lib/index.js @@ -11,6 +11,17 @@ const { loadDevices } = require('./tuya.loadDevices'); const { loadDeviceDetails } = require('./tuya.loadDeviceDetails'); const { setValue } = require('./tuya.setValue'); const { poll } = require('./tuya.poll'); +const { localScan } = require('./tuya.localScan'); +const { localPoll } = require('./tuya.localPoll'); +const { getStatus } = require('./tuya.getStatus'); +const { manualDisconnect } = require('./tuya.manualDisconnect'); +const { + tryReconnect, + scheduleQuickReconnects, + clearQuickReconnects, + startReconnect, + stopReconnect, +} = require('./tuya.reconnect'); const { STATUS } = require('./utils/tuya.constants'); @@ -20,6 +31,11 @@ const TuyaHandler = function TuyaHandler(gladys, serviceId) { this.connector = null; this.status = STATUS.NOT_INITIALIZED; + this.lastError = null; + this.autoReconnectAllowed = false; + this.reconnectInterval = null; + this.quickReconnectTimeouts = []; + this.quickReconnectInProgress = false; }; TuyaHandler.prototype.init = init; @@ -35,5 +51,14 @@ TuyaHandler.prototype.loadDevices = loadDevices; TuyaHandler.prototype.loadDeviceDetails = loadDeviceDetails; TuyaHandler.prototype.setValue = setValue; TuyaHandler.prototype.poll = poll; +TuyaHandler.prototype.localScan = localScan; +TuyaHandler.prototype.localPoll = localPoll; +TuyaHandler.prototype.getStatus = getStatus; +TuyaHandler.prototype.manualDisconnect = manualDisconnect; +TuyaHandler.prototype.tryReconnect = tryReconnect; +TuyaHandler.prototype.scheduleQuickReconnects = scheduleQuickReconnects; +TuyaHandler.prototype.clearQuickReconnects = clearQuickReconnects; +TuyaHandler.prototype.startReconnect = startReconnect; +TuyaHandler.prototype.stopReconnect = stopReconnect; module.exports = TuyaHandler; diff --git a/server/services/tuya/lib/tuya.connect.js b/server/services/tuya/lib/tuya.connect.js index af1e0d433e..d5127ad008 100644 --- a/server/services/tuya/lib/tuya.connect.js +++ b/server/services/tuya/lib/tuya.connect.js @@ -4,7 +4,73 @@ const logger = require('../../../utils/logger'); const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); -const { STATUS } = require('./utils/tuya.constants'); +const { STATUS, GLADYS_VARIABLES, API } = require('./utils/tuya.constants'); +const { buildConfigHash } = require('./utils/tuya.config'); + +/** + * @description Map Tuya errors to user-facing i18n keys and retry policy. + * @param {Error} error - Error thrown during connection. + * @returns {object|null} Mapping info or null when unknown. + * @example + * const mapped = mapConnectionError(new Error('GET_TOKEN_FAILED 2009, clientId is invalid')); + */ +const mapConnectionError = (error) => { + const rawMessage = error && error.message ? error.message : ''; + const message = rawMessage.toLowerCase(); + const code = error && error.code ? String(error.code).toLowerCase() : ''; + + if (code === '2009' || message.includes('clientid is invalid') || message.includes('get_token_failed 2009')) { + return { key: 'integration.tuya.setup.errorInvalidClientId', disableAutoReconnect: true }; + } + + if (code === '1004' || message.includes('sign invalid') || message.includes('get_token_failed 1004')) { + return { key: 'integration.tuya.setup.errorInvalidClientSecret', disableAutoReconnect: true }; + } + + if (code === '28841107' || message.includes('data center is suspended') || message.includes('data center')) { + return { key: 'integration.tuya.setup.errorInvalidEndpoint', disableAutoReconnect: true }; + } + + if ( + code === '1106' || + message.includes('permission deny') || + code === 'tuya_app_account_uid_missing' || + code === 'tuya_app_account_uid_invalid' + ) { + return { key: 'integration.tuya.setup.errorInvalidAppAccountUid', disableAutoReconnect: true }; + } + + return null; +}; + +/** + * @description Validate Tuya app account UID by calling the devices endpoint. + * @param {object} connector - Tuya connector instance. + * @param {string} appAccountId - Tuya app account UID. + * @returns {Promise} Resolves when valid. + * @example + * await validateAppAccount(connector, 'uid'); + */ +const validateAppAccount = async (connector, appAccountId) => { + if (!appAccountId) { + const error = new Error('TUYA_APP_ACCOUNT_UID_MISSING'); + error.code = 'TUYA_APP_ACCOUNT_UID_MISSING'; + throw error; + } + const response = await connector.request({ + method: 'GET', + path: `${API.PUBLIC_VERSION_1_0}/users/${appAccountId}/devices`, + query: { + page_no: 1, + page_size: 1, + }, + }); + if (response && response.success === false) { + const error = new Error(response.msg || response.message || 'TUYA_APP_ACCOUNT_UID_INVALID'); + error.code = response.code || 'TUYA_APP_ACCOUNT_UID_INVALID'; + throw error; + } +}; /** * @description Connect to Tuya cloud. @@ -13,14 +79,15 @@ const { STATUS } = require('./utils/tuya.constants'); * connect({baseUrl, accessKey, secretKey}); */ async function connect(configuration) { - const { baseUrl, accessKey, secretKey } = configuration; + const { baseUrl, accessKey, secretKey, appAccountId } = configuration; - if (!baseUrl || !accessKey || !secretKey) { + if (!baseUrl || !accessKey || !secretKey || !appAccountId) { this.status = STATUS.NOT_INITIALIZED; throw new ServiceNotConfiguredError('Tuya is not configured.'); } this.status = STATUS.CONNECTING; + this.lastError = null; this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, payload: { status: STATUS.CONNECTING }, @@ -35,17 +102,37 @@ async function connect(configuration) { }); try { - this.connector.client.init(); + await this.connector.client.init(); + await validateAppAccount(this.connector, appAccountId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', this.serviceId); + const configHash = buildConfigHash(configuration); + await this.gladys.variable.setValue(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, configHash, this.serviceId); + this.autoReconnectAllowed = true; this.status = STATUS.CONNECTED; logger.debug('Connected to Tuya'); } catch (e) { this.status = STATUS.ERROR; + const mapped = mapConnectionError(e); + let message = 'Unknown error'; + if (mapped) { + message = mapped.key; + } else if (e && e.message) { + message = e.message; + } + this.lastError = message; + if (mapped && mapped.disableAutoReconnect) { + this.autoReconnectAllowed = false; + } logger.error('Error connecting to Tuya:', e); + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR, + payload: { message }, + }); } this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, - payload: { status: this.status }, + payload: { status: this.status, error: this.lastError }, }); } diff --git a/server/services/tuya/lib/tuya.disconnect.js b/server/services/tuya/lib/tuya.disconnect.js index 1406c585be..ceb3938527 100644 --- a/server/services/tuya/lib/tuya.disconnect.js +++ b/server/services/tuya/lib/tuya.disconnect.js @@ -1,15 +1,26 @@ const logger = require('../../../utils/logger'); +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); const { STATUS } = require('./utils/tuya.constants'); /** * @description Disconnects service and dependencies. + * @param {object} [options] - Disconnect options. + * @param {boolean} [options.manual] - Whether this is a manual disconnect. * @example * disconnect(); */ -function disconnect() { - logger.debug('Disonnecting from Tuya...'); +function disconnect(options = {}) { + logger.debug('Disconnecting from Tuya...'); + const { manual = false } = options; + this.stopReconnect(); this.connector = null; this.status = STATUS.NOT_INITIALIZED; + this.lastError = null; + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, + payload: { status: this.status, manual_disconnect: manual }, + }); } module.exports = { diff --git a/server/services/tuya/lib/tuya.discoverDevices.js b/server/services/tuya/lib/tuya.discoverDevices.js index 333e6bb4ee..7fde526c73 100644 --- a/server/services/tuya/lib/tuya.discoverDevices.js +++ b/server/services/tuya/lib/tuya.discoverDevices.js @@ -1,13 +1,15 @@ const logger = require('../../../utils/logger'); const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); +const { mergeDevices } = require('../../../utils/device'); const { STATUS } = require('./utils/tuya.constants'); const { convertDevice } = require('./device/tuya.convertDevice'); +const { applyExistingLocalParams, normalizeExistingDevice } = require('./utils/tuya.deviceParams'); /** * @description Discover Tuya cloud devices. - * @returns {Promise} List of discovered devices;. + * @returns {Promise} List of discovered devices. * @example * await discoverDevices(); */ @@ -42,16 +44,40 @@ async function discoverDevices() { devices.map((device) => this.loadDeviceDetails(device)), ).then((results) => results.filter((result) => result.status === 'fulfilled').map((result) => result.value)); + this.discoveredDevices = this.discoveredDevices.map((device) => { + const cloudIp = device.cloud_ip || device.ip; + return { + ...device, + cloud_ip: cloudIp, + ip: null, + protocol_version: null, + local_override: false, + }; + }); + this.discoveredDevices = this.discoveredDevices .map((device) => ({ ...convertDevice(device), service_id: this.serviceId, })) - .filter((device) => { - const existInGladys = this.gladys.stateManager.get('deviceByExternalId', device.external_id); - return existInGladys === null; + .map((device) => { + const existing = normalizeExistingDevice(this.gladys.stateManager.get('deviceByExternalId', device.external_id)); + const deviceWithLocalParams = applyExistingLocalParams(device, existing); + return mergeDevices(deviceWithLocalParams, existing); }); + try { + const existingDevices = await this.gladys.device.get({ service: 'tuya' }); + const discoveredByExternalId = new Map(this.discoveredDevices.map((device) => [device.external_id, device])); + existingDevices.forEach((device) => { + if (device && device.external_id && !discoveredByExternalId.has(device.external_id)) { + this.discoveredDevices.push({ ...device, updatable: false }); + } + }); + } catch (e) { + logger.warn('Unable to load existing Tuya devices from Gladys', e); + } + this.status = STATUS.CONNECTED; this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { diff --git a/server/services/tuya/lib/tuya.getConfiguration.js b/server/services/tuya/lib/tuya.getConfiguration.js index 4b00a0a04e..a5dbe4954a 100644 --- a/server/services/tuya/lib/tuya.getConfiguration.js +++ b/server/services/tuya/lib/tuya.getConfiguration.js @@ -13,6 +13,8 @@ async function getConfiguration() { const endpoint = await this.gladys.variable.getValue(GLADYS_VARIABLES.ENDPOINT, this.serviceId); const accessKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_KEY, this.serviceId); const secretKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.SECRET_KEY, this.serviceId); + const appAccountId = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId); + const appUsername = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_USERNAME, this.serviceId); logger.debug(`Tuya configuration: baseUrl='${endpoint}' accessKey='${accessKey}'`); const baseUrl = TUYA_ENDPOINTS[endpoint] || TUYA_ENDPOINTS.china; @@ -21,6 +23,9 @@ async function getConfiguration() { baseUrl, accessKey, secretKey, + appUsername, + endpoint, + appAccountId, }; } diff --git a/server/services/tuya/lib/tuya.getStatus.js b/server/services/tuya/lib/tuya.getStatus.js new file mode 100644 index 0000000000..3d2914dbaf --- /dev/null +++ b/server/services/tuya/lib/tuya.getStatus.js @@ -0,0 +1,31 @@ +const { GLADYS_VARIABLES, STATUS } = require('./utils/tuya.constants'); +const { normalizeBoolean } = require('./utils/tuya.normalize'); + +/** + * @description Get Tuya connection and configuration status. + * @returns {Promise} Status object. + * @example + * const status = await getStatus(); + */ +async function getStatus() { + const endpoint = await this.gladys.variable.getValue(GLADYS_VARIABLES.ENDPOINT, this.serviceId); + const accessKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_KEY, this.serviceId); + const secretKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.SECRET_KEY, this.serviceId); + const appAccountId = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId); + const manualDisconnect = await this.gladys.variable.getValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, this.serviceId); + + const configured = Boolean(endpoint && accessKey && secretKey && appAccountId); + const manualDisconnectEnabled = normalizeBoolean(manualDisconnect); + + return { + status: this.status || STATUS.NOT_INITIALIZED, + connected: this.status === STATUS.CONNECTED, + configured, + error: this.lastError, + manual_disconnect: manualDisconnectEnabled, + }; +} + +module.exports = { + getStatus, +}; diff --git a/server/services/tuya/lib/tuya.init.js b/server/services/tuya/lib/tuya.init.js index a4cee62c93..d7e889442e 100644 --- a/server/services/tuya/lib/tuya.init.js +++ b/server/services/tuya/lib/tuya.init.js @@ -1,3 +1,9 @@ +const { GLADYS_VARIABLES, STATUS } = require('./utils/tuya.constants'); +const { buildConfigHash } = require('./utils/tuya.config'); +const { normalizeBoolean } = require('./utils/tuya.normalize'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + /** * @description Initialize service with properties and connect to devices. * @example @@ -5,7 +11,42 @@ */ async function init() { const configuration = await this.getConfiguration(); + const { baseUrl, accessKey, secretKey, appAccountId } = configuration || {}; + + if (!baseUrl || !accessKey || !secretKey || !appAccountId) { + this.status = STATUS.NOT_INITIALIZED; + this.autoReconnectAllowed = false; + throw new ServiceNotConfiguredError('Tuya is not configured.'); + } + + const lastConnectedHash = await this.gladys.variable.getValue( + GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, + this.serviceId, + ); + const currentHash = buildConfigHash(configuration); + const hasMatchingConfig = Boolean(lastConnectedHash && currentHash && lastConnectedHash === currentHash); + const manualDisconnect = await this.gladys.variable.getValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, this.serviceId); + const manualDisconnectEnabled = normalizeBoolean(manualDisconnect); + + if (manualDisconnectEnabled) { + this.autoReconnectAllowed = false; + this.status = STATUS.NOT_INITIALIZED; + this.lastError = null; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, + payload: { status: this.status, manual_disconnect: true }, + }); + return; + } + this.autoReconnectAllowed = hasMatchingConfig; + await this.connect(configuration); + + if (this.status === STATUS.CONNECTED) { + await this.loadDevices(); + } + + this.startReconnect(); } module.exports = { diff --git a/server/services/tuya/lib/tuya.loadDeviceDetails.js b/server/services/tuya/lib/tuya.loadDeviceDetails.js index 774910f65d..3404cd4da9 100644 --- a/server/services/tuya/lib/tuya.loadDeviceDetails.js +++ b/server/services/tuya/lib/tuya.loadDeviceDetails.js @@ -12,13 +12,72 @@ async function loadDeviceDetails(tuyaDevice) { const { id: deviceId } = tuyaDevice; logger.debug(`Loading ${deviceId} Tuya device specifications`); - const responsePage = await this.connector.request({ - method: 'GET', - path: `${API.VERSION_1_2}/devices/${deviceId}/specification`, - }); + const [specResult, detailsResult, propsResult, modelResult] = await Promise.allSettled([ + this.connector.request({ + method: 'GET', + path: `${API.VERSION_1_2}/devices/${deviceId}/specification`, + }), + this.connector.request({ + method: 'GET', + path: `${API.VERSION_1_0}/devices/${deviceId}`, + }), + this.connector.request({ + method: 'GET', + path: `${API.VERSION_2_0}/thing/${deviceId}/shadow/properties`, + }), + this.connector.request({ + method: 'GET', + path: `${API.VERSION_2_0}/thing/${deviceId}/model`, + }), + ]); - const { result } = responsePage; - return { ...tuyaDevice, specifications: result }; + if (specResult.status === 'rejected') { + const reason = specResult.reason && specResult.reason.message ? specResult.reason.message : specResult.reason; + logger.warn(`[Tuya] Failed to load specifications for ${deviceId}: ${reason}`); + } + if (detailsResult.status === 'rejected') { + const reason = + detailsResult.reason && detailsResult.reason.message ? detailsResult.reason.message : detailsResult.reason; + logger.warn(`[Tuya] Failed to load details for ${deviceId}: ${reason}`); + } + if (propsResult.status === 'rejected') { + const reason = propsResult.reason && propsResult.reason.message ? propsResult.reason.message : propsResult.reason; + logger.warn(`[Tuya] Failed to load properties for ${deviceId}: ${reason}`); + } + if (modelResult.status === 'rejected') { + const reason = modelResult.reason && modelResult.reason.message ? modelResult.reason.message : modelResult.reason; + logger.warn(`[Tuya] Failed to load thing model for ${deviceId}: ${reason}`); + } + + const specifications = specResult.status === 'fulfilled' ? specResult.value.result || {} : {}; + const details = detailsResult.status === 'fulfilled' ? detailsResult.value.result || {} : {}; + const properties = propsResult.status === 'fulfilled' ? propsResult.value.result || {} : {}; + const modelPayload = modelResult.status === 'fulfilled' ? modelResult.value.result || null : null; + const rawModel = + modelPayload && Object.prototype.hasOwnProperty.call(modelPayload, 'model') ? modelPayload.model : modelPayload; + let thingModel = null; + if (typeof rawModel === 'string') { + try { + thingModel = JSON.parse(rawModel); + } catch (e) { + logger.warn(`[Tuya] Invalid thing model JSON for ${deviceId}`, e); + thingModel = null; + } + } else if (rawModel && typeof rawModel === 'object') { + thingModel = rawModel; + } + + const category = details.category || tuyaDevice.category; + const specificationsWithCategory = + category && !specifications.category ? { ...specifications, category } : specifications; + + return { + ...tuyaDevice, + ...details, + specifications: specificationsWithCategory, + properties, + thing_model: thingModel, + }; } module.exports = { diff --git a/server/services/tuya/lib/tuya.loadDevices.js b/server/services/tuya/lib/tuya.loadDevices.js index 09da27c327..9f7550e5c5 100644 --- a/server/services/tuya/lib/tuya.loadDevices.js +++ b/server/services/tuya/lib/tuya.loadDevices.js @@ -3,33 +3,56 @@ const { API, GLADYS_VARIABLES } = require('./utils/tuya.constants'); /** * @description Discover Tuya cloud devices. - * @param {string} lastRowKey - Key of last row to start with. + * @param {number} pageNo - Page number. + * @param {number} pageSize - Page size. * @returns {Promise} List of discovered devices. * @example * await loadDevices(); */ -async function loadDevices(lastRowKey = null) { +async function loadDevices(pageNo = 1, pageSize = 100) { + if (!Number.isInteger(pageNo) || pageNo <= 0) { + throw new Error('pageNo must be a positive integer'); + } + if (!Number.isInteger(pageSize) || pageSize <= 0) { + throw new Error('pageSize must be a positive integer'); + } const sourceId = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId); + if (!sourceId) { + throw new Error('Tuya APP_ACCOUNT_UID is missing'); + } const responsePage = await this.connector.request({ method: 'GET', - path: `${API.VERSION_1_3}/devices`, + path: `${API.PUBLIC_VERSION_1_0}/users/${sourceId}/devices`, query: { - last_row_key: lastRowKey, - source_type: 'tuyaUser', - source_id: sourceId, + page_no: pageNo, + page_size: pageSize, }, }); + if (!responsePage) { + throw new Error('Tuya API returned no response'); + } + if (responsePage.success === false) { + const message = responsePage.msg || responsePage.message || responsePage.code || 'Tuya API error'; + throw new Error(message); + } - const { result } = responsePage; - const { list, has_more: hasMore, last_row_key: nextLastRowKey, total } = result; + const result = responsePage.result || []; + const list = Array.isArray(result) ? result : result.list || []; + let hasMore = list.length === pageSize; + if (!Array.isArray(result) && typeof result.has_more === 'boolean') { + hasMore = result.has_more; + } if (hasMore) { - const nextResult = await this.loadDevices(nextLastRowKey); - nextResult.forEach((device) => list.push(device)); + if (list.length === 0) { + throw new Error('Tuya API pagination did not advance (has_more=true with empty page)'); + } + const nextResult = await this.loadDevices(pageNo + 1, pageSize); + list.push(...nextResult); } - logger.debug(`${list.length} / ${total} Tuya devices loaded`); + logger.debug(`${list.length} Tuya devices loaded`); return list; } diff --git a/server/services/tuya/lib/tuya.localPoll.js b/server/services/tuya/lib/tuya.localPoll.js new file mode 100644 index 0000000000..d46e9d9160 --- /dev/null +++ b/server/services/tuya/lib/tuya.localPoll.js @@ -0,0 +1,163 @@ +const TuyAPI = require('tuyapi'); +const logger = require('../../../utils/logger'); +const { BadParameters } = require('../../../utils/coreErrors'); +const { mergeDevices } = require('../../../utils/device'); +const { DEVICE_PARAM_NAME } = require('./utils/tuya.constants'); +const { normalizeExistingDevice, upsertParam } = require('./utils/tuya.deviceParams'); + +/** + * @description Poll a Tuya device locally to retrieve DPS map. + * @param {object} payload - Local connection info. + * @returns {Promise} DPS map. + * @example + * await localPoll({ deviceId: 'id', ip: '1.1.1.1', localKey: 'key', protocolVersion: '3.3' }); + */ +async function localPoll(payload) { + const { deviceId, ip, localKey, protocolVersion, timeoutMs = 3000, fastScan = false } = payload || {}; + const effectiveTimeout = protocolVersion === '3.5' && !fastScan ? Math.max(timeoutMs, 5000) : timeoutMs; + + if (!deviceId || !ip || !localKey || !protocolVersion) { + throw new BadParameters('Missing local connection parameters'); + } + + const tuyaLocal = new TuyAPI({ + id: deviceId, + key: localKey, + ip, + version: protocolVersion, + }); + let lastError = null; + const onError = (err) => { + lastError = err; + logger.info(`[Tuya][localPoll] socket error for device=${deviceId}: ${err.message}`); + }; + tuyaLocal.on('error', onError); + + const runGet = async (options) => { + let errorListener; + let timeoutId; + let resolved = false; + const cleanup = async () => { + if (resolved) { + return; + } + resolved = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (errorListener) { + tuyaLocal.removeListener('error', errorListener); + } + try { + await tuyaLocal.disconnect(); + } catch (err) { + // ignore + } + }; + try { + const operation = (async () => { + await tuyaLocal.connect(); + const data = await tuyaLocal.get(options); + return data; + })(); + const data = await Promise.race([ + operation, + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new BadParameters('Local poll timeout')), effectiveTimeout); + }), + new Promise((_, reject) => { + errorListener = (err) => { + reject(new BadParameters(`Local poll socket error: ${err.message}`)); + }; + tuyaLocal.once('error', errorListener); + }), + ]); + await cleanup(); + return data; + } catch (e) { + await cleanup(); + throw e; + } + }; + + try { + const attempts = + protocolVersion === '3.5' ? [{ schema: true }, { schema: true, dps: [1] }, {}] : [{ schema: true }]; + const tryAttempt = async (index) => { + try { + return await runGet(attempts[index]); + } catch (e) { + if (index >= attempts.length - 1) { + throw e; + } + return tryAttempt(index + 1); + } + }; + const data = await tryAttempt(0); + if (!data || typeof data !== 'object' || !data.dps) { + const errorMessage = + typeof data === 'string' ? `Invalid local poll response: ${data}` : 'Invalid local poll response'; + throw new BadParameters(errorMessage); + } + logger.debug(`[Tuya][localPoll] device=${deviceId} dps=${JSON.stringify(data)}`); + tuyaLocal.removeListener('error', onError); + return data; + } catch (e) { + if (lastError && (!e || e.message !== lastError.message)) { + logger.info(`[Tuya][localPoll] last socket error for device=${deviceId}: ${lastError.message}`); + } + logger.warn(`[Tuya][localPoll] failed for device=${deviceId}`, e); + tuyaLocal.removeListener('error', onError); + throw e; + } +} + +/** + * @description Update discovered device list after a successful local poll. + * @param {object} tuyaManager - Tuya handler instance. + * @param {object} payload - Local poll payload. + * @returns {object|null} Updated device when found. + * @example + * updateDiscoveredDeviceAfterLocalPoll(tuyaManager, { deviceId: 'id', ip: '1.1.1.1', protocolVersion: '3.3' }); + */ +function updateDiscoveredDeviceAfterLocalPoll(tuyaManager, payload) { + const { deviceId, ip, protocolVersion, localKey } = payload || {}; + if (!deviceId || !tuyaManager || !Array.isArray(tuyaManager.discoveredDevices)) { + return null; + } + const externalId = `tuya:${deviceId}`; + const deviceIndex = tuyaManager.discoveredDevices.findIndex((device) => device.external_id === externalId); + if (deviceIndex < 0) { + return null; + } + + let device = { ...tuyaManager.discoveredDevices[deviceIndex] }; + device.protocol_version = protocolVersion; + device.ip = ip; + device.local_override = true; + if (localKey) { + device.local_key = localKey; + } + device.params = Array.isArray(device.params) ? [...device.params] : []; + upsertParam(device.params, DEVICE_PARAM_NAME.IP_ADDRESS, ip); + upsertParam(device.params, DEVICE_PARAM_NAME.PROTOCOL_VERSION, protocolVersion); + upsertParam(device.params, DEVICE_PARAM_NAME.LOCAL_KEY, localKey); + upsertParam(device.params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE, true); + upsertParam(device.params, DEVICE_PARAM_NAME.PRODUCT_ID, device.product_id); + upsertParam(device.params, DEVICE_PARAM_NAME.PRODUCT_KEY, device.product_key); + + if (tuyaManager.gladys && tuyaManager.gladys.stateManager) { + const existing = normalizeExistingDevice( + tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id), + ); + device = mergeDevices(device, existing); + } + + tuyaManager.discoveredDevices[deviceIndex] = device; + return device; +} + +module.exports = { + localPoll, + updateDiscoveredDeviceAfterLocalPoll, +}; diff --git a/server/services/tuya/lib/tuya.localScan.js b/server/services/tuya/lib/tuya.localScan.js new file mode 100644 index 0000000000..cceaa21a18 --- /dev/null +++ b/server/services/tuya/lib/tuya.localScan.js @@ -0,0 +1,192 @@ +const dgram = require('dgram'); +const { UDP_KEY } = require('tuyapi/lib/config'); +const { MessageParser } = require('tuyapi/lib/message-parser'); +const logger = require('../../../utils/logger'); +const { mergeDevices } = require('../../../utils/device'); +const { convertDevice } = require('./device/tuya.convertDevice'); +const { + applyExistingLocalOverride, + normalizeExistingDevice, + updateDiscoveredDeviceWithLocalInfo, +} = require('./utils/tuya.deviceParams'); + +const DEFAULT_PORTS = [6666, 6667, 7000]; +/** + * @description Scan local network for Tuya devices (UDP broadcast). + * @param {object} options - Scan options. + * @param {number} [options.timeoutSeconds=10] - Scan duration in seconds. + * @returns {Promise} Map of deviceId -> { ip, version, productKey }. + * @example + * await localScan({ timeoutSeconds: 10 }); + */ +async function localScan(options = {}) { + const timeoutSeconds = options.timeoutSeconds || 10; + const devices = {}; + const portErrors = {}; + const sockets = []; + const parser = new MessageParser({ key: UDP_KEY, version: 3.1 }); + + logger.info(`[Tuya][localScan] Starting udp scan for ${timeoutSeconds}s on ports ${DEFAULT_PORTS.join(', ')}`); + + const onMessage = (message, rinfo) => { + let payload; + const byteLen = message ? message.length : 0; + const remote = rinfo ? `${rinfo.address}:${rinfo.port}` : 'unknown'; + const source = rinfo && rinfo.source ? rinfo.source : 'udp'; + logger.debug(`[Tuya][localScan] Packet received (${source}) from ${remote} len=${byteLen}`); + try { + const parsed = parser.parse(message); + const safePayload = + parsed && parsed[0] && parsed[0].payload + ? { + gwId: parsed[0].payload.gwId, + devId: parsed[0].payload.devId, + id: parsed[0].payload.id, + version: parsed[0].payload.version, + hasIp: !!parsed[0].payload.ip, + } + : null; + logger.debug(`[Tuya][localScan] Parsed packet from ${remote}: ${JSON.stringify(safePayload)}`); + payload = parsed && parsed[0] && parsed[0].payload; + } catch (e) { + logger.info(`[Tuya][localScan] Unable to parse payload from ${remote} (len=${byteLen}): ${e.message}`); + return; + } + + if (!payload || typeof payload !== 'object') { + logger.info(`[Tuya][localScan] Ignoring payload from ${remote} (len=${byteLen}): invalid payload`); + return; + } + + const { gwId, devId, id, ip, version, productKey } = payload; + const resolvedIp = ip || (rinfo && rinfo.address); + const deviceId = gwId || devId || id; + + if (!deviceId) { + logger.info(`[Tuya][localScan] Ignoring payload from ${remote} (len=${byteLen}): missing deviceId`); + return; + } + + const isNew = !devices[deviceId]; + devices[deviceId] = { + ip: resolvedIp, + version, + productKey, + }; + if (isNew) { + logger.info(`[Tuya][localScan] Found device ${deviceId} ip=${ip || 'unknown'} version=${version || 'unknown'}`); + } + }; + + DEFAULT_PORTS.forEach((port) => { + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + socket.on('message', onMessage); + socket.on('error', (err) => { + portErrors[port] = err && err.message ? err.message : 'unknown'; + logger.info(`[Tuya][localScan] UDP socket error on port ${port}: ${err.message}`); + }); + socket.on('listening', () => { + try { + const address = socket.address(); + logger.info(`[Tuya][localScan] Listening on ${address.address}:${address.port}`); + } catch (e) { + logger.info(`[Tuya][localScan] Listening on port ${port}`); + } + }); + socket.bind({ port, address: '0.0.0.0', exclusive: false }); + sockets.push(socket); + }); + + await new Promise((resolve) => { + setTimeout(resolve, timeoutSeconds * 1000); + }); + + sockets.forEach((socket) => { + try { + socket.close(); + } catch (e) { + // ignore + } + }); + + logger.info(`[Tuya][localScan] Scan complete. Found ${Object.keys(devices).length} device(s).`); + return { devices, portErrors }; +} + +/** + * @description Build local scan response and update discovered devices. + * @param {object} tuyaManager - Tuya handler instance. + * @param {object} localScanResult - Result of UDP scan. + * @returns {object} API response payload. + * @example + * buildLocalScanResponse(tuyaManager, { devices: {}, portErrors: {} }); + */ +function buildLocalScanResponse(tuyaManager, localScanResult) { + const localDevicesById = (localScanResult && localScanResult.devices) || {}; + const portErrors = (localScanResult && localScanResult.portErrors) || {}; + const mergeWithExisting = (device) => { + if (!tuyaManager || !tuyaManager.gladys || !tuyaManager.gladys.stateManager) { + return device; + } + const existing = normalizeExistingDevice( + tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id), + ); + const withLocalOverride = applyExistingLocalOverride(device, existing); + return mergeDevices(withLocalOverride, existing); + }; + const buildLocalDiscoveredDevice = (deviceId, localInfo) => + convertDevice.call(tuyaManager, { + id: deviceId, + name: localInfo && localInfo.name ? localInfo.name : `Tuya ${deviceId}`, + product_key: localInfo && localInfo.productKey, + ip: localInfo && localInfo.ip, + protocol_version: localInfo && localInfo.version, + local_override: true, + specifications: { + functions: [], + status: [], + }, + }); + + if (tuyaManager && Array.isArray(tuyaManager.discoveredDevices)) { + const updatedDevices = tuyaManager.discoveredDevices.map((device) => { + const deviceId = device.external_id && device.external_id.split(':')[1]; + const localInfo = localDevicesById[deviceId]; + return updateDiscoveredDeviceWithLocalInfo(device, localInfo); + }); + const mergedDevices = updatedDevices.map((device) => mergeWithExisting(device)); + const knownExternalIds = new Set(mergedDevices.map((device) => device.external_id)); + const localOnlyDevices = Object.entries(localDevicesById) + .map(([deviceId, localInfo]) => buildLocalDiscoveredDevice(deviceId, localInfo)) + .filter((device) => !knownExternalIds.has(device.external_id)) + .map((device) => mergeWithExisting(device)); + const allDiscoveredDevices = [...mergedDevices, ...localOnlyDevices]; + tuyaManager.discoveredDevices = allDiscoveredDevices; + return { + devices: allDiscoveredDevices, + local_devices: localDevicesById, + port_errors: portErrors, + }; + } + + if (tuyaManager && Object.keys(localDevicesById).length > 0) { + const localDiscoveredDevices = Object.entries(localDevicesById) + .map(([deviceId, localInfo]) => buildLocalDiscoveredDevice(deviceId, localInfo)) + .map((device) => mergeWithExisting(device)); + return { + devices: localDiscoveredDevices, + local_devices: localDevicesById, + port_errors: portErrors, + }; + } + + return { + local_devices: localDevicesById, + port_errors: portErrors, + }; +} + +module.exports = { + localScan, + buildLocalScanResponse, +}; diff --git a/server/services/tuya/lib/tuya.manualDisconnect.js b/server/services/tuya/lib/tuya.manualDisconnect.js new file mode 100644 index 0000000000..732f1f877e --- /dev/null +++ b/server/services/tuya/lib/tuya.manualDisconnect.js @@ -0,0 +1,15 @@ +const { GLADYS_VARIABLES } = require('./utils/tuya.constants'); + +/** + * @description Manually disconnect from Tuya cloud and disable auto-reconnect. + * @example + * await manualDisconnect(); + */ +async function manualDisconnect() { + await this.gladys.variable.setValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, 'true', this.serviceId); + await this.disconnect({ manual: true }); +} + +module.exports = { + manualDisconnect, +}; diff --git a/server/services/tuya/lib/tuya.poll.js b/server/services/tuya/lib/tuya.poll.js index a3d084201e..4f27303344 100644 --- a/server/services/tuya/lib/tuya.poll.js +++ b/server/services/tuya/lib/tuya.poll.js @@ -3,6 +3,19 @@ const { readValues } = require('./device/tuya.deviceMapping'); const { API } = require('./utils/tuya.constants'); const { EVENTS } = require('../../../utils/constants'); +const getCurrentFeatureLastValue = (gladys, deviceFeature) => { + if (!deviceFeature) { + return undefined; + } + if (deviceFeature.selector && gladys && gladys.stateManager && typeof gladys.stateManager.get === 'function') { + const currentFeature = gladys.stateManager.get('deviceFeature', deviceFeature.selector); + if (currentFeature && Object.prototype.hasOwnProperty.call(currentFeature, 'last_value')) { + return currentFeature.last_value; + } + } + return deviceFeature.last_value; +}; + /** * * @description Poll values of an Tuya device. @@ -37,8 +50,9 @@ async function poll(device) { const value = values[code]; const transformedValue = readValues[deviceFeature.category][deviceFeature.type](value); + const currentLastValue = getCurrentFeatureLastValue(this.gladys, deviceFeature); - if (deviceFeature.last_value !== transformedValue) { + if (currentLastValue !== transformedValue) { if (transformedValue !== null && transformedValue !== undefined) { this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { device_feature_external_id: deviceFeature.external_id, diff --git a/server/services/tuya/lib/tuya.reconnect.js b/server/services/tuya/lib/tuya.reconnect.js new file mode 100644 index 0000000000..3eda63323b --- /dev/null +++ b/server/services/tuya/lib/tuya.reconnect.js @@ -0,0 +1,127 @@ +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/tuya.constants'); + +const QUICK_RECONNECT_ATTEMPTS = 3; +const QUICK_RECONNECT_DELAY_MS = 1000 * 3; +const RECONNECT_INTERVAL_MS = 1000 * 60 * 30; + +/** + * @description Attempt to reconnect to Tuya if configured and not manually disconnected. + * @returns {Promise} Returns true if reconnect should be retried, false otherwise. + * @example + * await this.tryReconnect(); + */ +async function tryReconnect() { + try { + if (!this.autoReconnectAllowed) { + return false; + } + const status = await this.getStatus(); + if (!status.configured || status.manual_disconnect) { + return false; + } + if ( + this.status === STATUS.CONNECTED || + this.status === STATUS.CONNECTING || + this.status === STATUS.DISCOVERING_DEVICES + ) { + return false; + } + logger.info('Tuya is disconnected, attempting auto-reconnect...'); + const configuration = await this.getConfiguration(); + await this.connect(configuration); + return this.status !== STATUS.CONNECTED; + } catch (e) { + logger.warn('Auto-reconnect to Tuya failed:', e.message || e); + return true; + } +} + +/** + * @description Schedule quick reconnect attempts when disconnected. + * @returns {Promise} Resolves once the current attempt is finished. + * @example + * await this.scheduleQuickReconnects(); + */ +function scheduleQuickReconnects() { + if (this.quickReconnectInProgress) { + return Promise.resolve(); + } + this.quickReconnectInProgress = true; + let attempts = 0; + + const runAttempt = async () => { + attempts += 1; + const shouldRetry = await this.tryReconnect(); + const isConnecting = + this.status === STATUS.CONNECTED || + this.status === STATUS.CONNECTING || + this.status === STATUS.DISCOVERING_DEVICES; + + if (!shouldRetry || isConnecting) { + this.clearQuickReconnects(); + return; + } + + if (attempts < QUICK_RECONNECT_ATTEMPTS) { + const timeoutId = setTimeout(runAttempt, QUICK_RECONNECT_DELAY_MS); + if (timeoutId && typeof timeoutId.unref === 'function') { + timeoutId.unref(); + } + this.quickReconnectTimeouts.push(timeoutId); + return; + } + + this.quickReconnectInProgress = false; + }; + + return runAttempt(); +} + +/** + * @description Clear pending quick reconnect timers and reset state. + * @example + * this.clearQuickReconnects(); + */ +function clearQuickReconnects() { + if (this.quickReconnectTimeouts && this.quickReconnectTimeouts.length > 0) { + this.quickReconnectTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + this.quickReconnectTimeouts = []; + } + this.quickReconnectInProgress = false; +} + +/** + * @description Start the reconnect manager (quick reconnects + periodic interval). + * @example + * this.startReconnect(); + */ +function startReconnect() { + if (this.status !== STATUS.CONNECTED && this.autoReconnectAllowed) { + this.scheduleQuickReconnects(); + } + if (!this.reconnectInterval) { + this.reconnectInterval = setInterval(() => this.scheduleQuickReconnects(), RECONNECT_INTERVAL_MS); + } +} + +/** + * @description Stop the reconnect manager and clear all timers. + * @example + * this.stopReconnect(); + */ +function stopReconnect() { + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + } + this.clearQuickReconnects(); +} + +module.exports = { + tryReconnect, + scheduleQuickReconnects, + clearQuickReconnects, + startReconnect, + stopReconnect, +}; diff --git a/server/services/tuya/lib/tuya.saveConfiguration.js b/server/services/tuya/lib/tuya.saveConfiguration.js index 5d72ac2fe7..a4c0db5a72 100644 --- a/server/services/tuya/lib/tuya.saveConfiguration.js +++ b/server/services/tuya/lib/tuya.saveConfiguration.js @@ -11,11 +11,14 @@ const { GLADYS_VARIABLES } = require('./utils/tuya.constants'); */ async function saveConfiguration(configuration) { logger.debug('Saving Tuya configuration...'); - const { endpoint, accessKey, secretKey, appAccountId } = configuration; + const { endpoint, accessKey, secretKey, appAccountId, appUsername } = configuration; await this.gladys.variable.setValue(GLADYS_VARIABLES.ENDPOINT, endpoint, this.serviceId); await this.gladys.variable.setValue(GLADYS_VARIABLES.ACCESS_KEY, accessKey, this.serviceId); await this.gladys.variable.setValue(GLADYS_VARIABLES.SECRET_KEY, secretKey, this.serviceId); await this.gladys.variable.setValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, appAccountId, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.APP_USERNAME, appUsername, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, '', this.serviceId); return configuration; } diff --git a/server/services/tuya/lib/tuya.setValue.js b/server/services/tuya/lib/tuya.setValue.js index b2d6083e7a..ad7642e2f3 100644 --- a/server/services/tuya/lib/tuya.setValue.js +++ b/server/services/tuya/lib/tuya.setValue.js @@ -1,7 +1,29 @@ +const TuyAPI = require('tuyapi'); const logger = require('../../../utils/logger'); const { API } = require('./utils/tuya.constants'); const { BadParameters } = require('../../../utils/coreErrors'); const { writeValues } = require('./device/tuya.deviceMapping'); +const { DEVICE_PARAM_NAME } = require('./utils/tuya.constants'); +const { normalizeBoolean } = require('./utils/tuya.normalize'); + +const getParamValue = (params, name) => { + const found = (params || []).find((param) => param.name === name); + return found ? found.value : undefined; +}; + +const getLocalDpsFromCode = (code) => { + if (!code) { + return null; + } + if (code === 'switch') { + return 1; + } + const match = code.match(/_(\d+)$/); + if (match) { + return parseInt(match[1], 10); + } + return null; +}; /** * @description Send the new device value over device protocol. @@ -22,9 +44,48 @@ async function setValue(device, deviceFeature, value) { throw new BadParameters(`Tuya device external_id is invalid: "${externalId}" have no network indicator`); } - const transformedValue = writeValues[deviceFeature.category][deviceFeature.type](value); + const writeCategory = writeValues[deviceFeature.category]; + const writeFn = writeCategory ? writeCategory[deviceFeature.type] : null; + const transformedValue = writeFn ? writeFn(value) : value; logger.debug(`Change value for devices ${topic}/${command} to value ${transformedValue}...`); + const params = device.params || []; + const ipAddress = getParamValue(params, DEVICE_PARAM_NAME.IP_ADDRESS); + const localKey = getParamValue(params, DEVICE_PARAM_NAME.LOCAL_KEY); + const protocolVersion = getParamValue(params, DEVICE_PARAM_NAME.PROTOCOL_VERSION); + const localOverride = normalizeBoolean(getParamValue(params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE)); + + const hasLocalConfig = ipAddress && localKey && protocolVersion && localOverride === true; + + const localDps = getLocalDpsFromCode(command); + + if (hasLocalConfig && localDps !== null) { + const tuyaLocal = new TuyAPI({ + id: topic, + key: localKey, + ip: ipAddress, + version: protocolVersion, + }); + let connected = false; + try { + await tuyaLocal.connect(); + connected = true; + await tuyaLocal.set({ dps: localDps, set: transformedValue }); + logger.debug(`[Tuya][setValue][local] device=${topic} dps=${localDps} value=${transformedValue}`); + return; + } catch (e) { + logger.warn(`[Tuya][setValue][local] failed, fallback to cloud`, e); + } finally { + if (connected) { + try { + await tuyaLocal.disconnect(); + } catch (disconnectError) { + logger.warn('[Tuya][setValue][local] disconnect failed', disconnectError); + } + } + } + } + const response = await this.connector.request({ method: 'POST', path: `${API.VERSION_1_0}/devices/${topic}/commands`, diff --git a/server/services/tuya/lib/utils/tuya.config.js b/server/services/tuya/lib/utils/tuya.config.js new file mode 100644 index 0000000000..f309a294d1 --- /dev/null +++ b/server/services/tuya/lib/utils/tuya.config.js @@ -0,0 +1,25 @@ +const crypto = require('crypto'); + +/** + * @description Build a stable hash for the Tuya configuration. + * @param {object} config - Tuya configuration. + * @returns {string} SHA-256 hash. + * @example + * const hash = buildConfigHash({ endpoint: 'eu', accessKey: 'key', secretKey: 'secret', appAccountId: 'uid' }); + */ +const buildConfigHash = (config = {}) => { + const payload = JSON.stringify({ + endpoint: config.endpoint || '', + accessKey: config.accessKey || '', + secretKey: config.secretKey || '', + appAccountId: config.appAccountId || '', + }); + return crypto + .createHash('sha256') + .update(payload) + .digest('hex'); +}; + +module.exports = { + buildConfigHash, +}; diff --git a/server/services/tuya/lib/utils/tuya.constants.js b/server/services/tuya/lib/utils/tuya.constants.js index a4e0188b94..def5025421 100644 --- a/server/services/tuya/lib/utils/tuya.constants.js +++ b/server/services/tuya/lib/utils/tuya.constants.js @@ -5,6 +5,9 @@ const GLADYS_VARIABLES = { ACCESS_TOKEN: 'TUYA_ACCESS_TOKEN', REFRESH_TOKEN: 'TUYA_REFRESH_TOKEN', APP_ACCOUNT_UID: 'TUYA_APP_ACCOUNT_UID', + APP_USERNAME: 'TUYA_APP_USERNAME', + MANUAL_DISCONNECT: 'TUYA_MANUAL_DISCONNECT', + LAST_CONNECTED_CONFIG_HASH: 'TUYA_LAST_CONNECTED_CONFIG_HASH', }; const TUYA_ENDPOINTS = { @@ -25,10 +28,23 @@ const STATUS = { }; const API = { + PUBLIC_VERSION_1_0: '/v1.0', VERSION_1_0: '/v1.0/iot-03', VERSION_1_1: '/v1.1/iot-03', VERSION_1_2: '/v1.2/iot-03', VERSION_1_3: '/v1.3/iot-03', + VERSION_2_0: '/v2.0/cloud', +}; + +const DEVICE_PARAM_NAME = { + DEVICE_ID: 'DEVICE_ID', + LOCAL_KEY: 'LOCAL_KEY', + IP_ADDRESS: 'IP_ADDRESS', + PROTOCOL_VERSION: 'PROTOCOL_VERSION', + CLOUD_IP: 'CLOUD_IP', + LOCAL_OVERRIDE: 'LOCAL_OVERRIDE', + PRODUCT_ID: 'PRODUCT_ID', + PRODUCT_KEY: 'PRODUCT_KEY', }; module.exports = { @@ -36,4 +52,5 @@ module.exports = { TUYA_ENDPOINTS, STATUS, API, + DEVICE_PARAM_NAME, }; diff --git a/server/services/tuya/lib/utils/tuya.deviceParams.js b/server/services/tuya/lib/utils/tuya.deviceParams.js new file mode 100644 index 0000000000..e4bae3b1b0 --- /dev/null +++ b/server/services/tuya/lib/utils/tuya.deviceParams.js @@ -0,0 +1,111 @@ +const { DEVICE_PARAM_NAME } = require('./tuya.constants'); +const { normalizeBoolean } = require('./tuya.normalize'); + +const upsertParam = (params, name, value) => { + if (value === undefined || value === null) { + return; + } + const index = params.findIndex((param) => param.name === name); + if (index >= 0) { + params[index] = { ...params[index], value }; + } else { + params.push({ name, value }); + } +}; + +const getParamValue = (params, name) => { + if (!Array.isArray(params)) { + return undefined; + } + const found = params.find((param) => param.name === name); + return found ? found.value : undefined; +}; + +const normalizeExistingDevice = (device) => { + if (!device || !Array.isArray(device.params)) { + return device; + } + const normalizedParams = device.params.map((param) => { + if (param.name !== DEVICE_PARAM_NAME.LOCAL_OVERRIDE) { + return param; + } + if (param.value === undefined || param.value === null) { + return param; + } + return { ...param, value: normalizeBoolean(param.value) }; + }); + return { ...device, params: normalizedParams }; +}; + +const updateDiscoveredDeviceWithLocalInfo = (device, localInfo) => { + if (!device || !localInfo) { + return device; + } + const updated = { ...device }; + if (localInfo.version !== undefined && localInfo.version !== null) { + updated.protocol_version = localInfo.version; + } + updated.ip = localInfo.ip || updated.ip; + if (localInfo.productKey !== undefined && localInfo.productKey !== null) { + updated.product_key = localInfo.productKey; + } + updated.params = Array.isArray(updated.params) ? [...updated.params] : []; + upsertParam(updated.params, DEVICE_PARAM_NAME.IP_ADDRESS, updated.ip); + upsertParam(updated.params, DEVICE_PARAM_NAME.PROTOCOL_VERSION, updated.protocol_version); + upsertParam(updated.params, DEVICE_PARAM_NAME.PRODUCT_KEY, updated.product_key); + return updated; +}; + +const applyExistingLocalParams = (device, existingDevice) => { + if (!existingDevice) { + return device; + } + const params = Array.isArray(device.params) ? [...device.params] : []; + const ipValue = getParamValue(existingDevice.params, DEVICE_PARAM_NAME.IP_ADDRESS); + const protocolValue = getParamValue(existingDevice.params, DEVICE_PARAM_NAME.PROTOCOL_VERSION); + const rawLocalOverrideValue = getParamValue(existingDevice.params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE); + const localOverrideValue = + rawLocalOverrideValue !== undefined && rawLocalOverrideValue !== null + ? normalizeBoolean(rawLocalOverrideValue) + : rawLocalOverrideValue; + + upsertParam(params, DEVICE_PARAM_NAME.IP_ADDRESS, ipValue); + upsertParam(params, DEVICE_PARAM_NAME.PROTOCOL_VERSION, protocolValue); + upsertParam(params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE, localOverrideValue); + + const resolvedLocalOverride = + localOverrideValue !== undefined && localOverrideValue !== null ? localOverrideValue : device.local_override; + + return { + ...device, + params, + ip: ipValue !== undefined && ipValue !== null ? ipValue : device.ip, + protocol_version: protocolValue !== undefined && protocolValue !== null ? protocolValue : device.protocol_version, + local_override: resolvedLocalOverride, + }; +}; + +const applyExistingLocalOverride = (device, existingDevice) => { + if (!existingDevice || !Array.isArray(existingDevice.params)) { + return device; + } + const overrideParam = existingDevice.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE); + if (!overrideParam || overrideParam.value === undefined || overrideParam.value === null) { + return device; + } + const updated = { ...device }; + updated.params = Array.isArray(updated.params) ? [...updated.params] : []; + const normalizedOverride = normalizeBoolean(overrideParam.value); + upsertParam(updated.params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE, normalizedOverride); + updated.local_override = normalizedOverride; + return updated; +}; + +module.exports = { + applyExistingLocalOverride, + applyExistingLocalParams, + getParamValue, + normalizeExistingDevice, + updateDiscoveredDeviceWithLocalInfo, + upsertParam, +}; diff --git a/server/services/tuya/lib/utils/tuya.normalize.js b/server/services/tuya/lib/utils/tuya.normalize.js new file mode 100644 index 0000000000..3bcd035346 --- /dev/null +++ b/server/services/tuya/lib/utils/tuya.normalize.js @@ -0,0 +1,10 @@ +const normalizeBoolean = (value) => { + if (value === true || value === 1 || value === '1') { + return true; + } + return typeof value === 'string' && ['true', 'on'].includes(value.trim().toLowerCase()); +}; + +module.exports = { + normalizeBoolean, +}; diff --git a/server/services/tuya/package-lock.json b/server/services/tuya/package-lock.json index a760b2762b..73a7164847 100644 --- a/server/services/tuya/package-lock.json +++ b/server/services/tuya/package-lock.json @@ -18,7 +18,8 @@ "win32" ], "dependencies": { - "@tuya/tuya-connector-nodejs": "^2.1.2" + "@tuya/tuya-connector-nodejs": "^2.1.2", + "tuyapi": "^7.2.0" } }, "node_modules/@tuya/tuya-connector-nodejs": { @@ -30,6 +31,12 @@ "qs": "^6.10.1" } }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "license": "MIT" + }, "node_modules/axios": { "version": "0.21.4", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", @@ -50,6 +57,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -109,6 +132,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -117,6 +146,56 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.1.tgz", + "integrity": "sha512-miQiSxLYPYBxGkrldecZC18OTLjdUqnlRebGzPRiVxB8mco7usCmm7hFuxiTvp93K18JnLtE4KMMycjAu/cQQg==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.2.0.tgz", + "integrity": "sha512-jPH38/MRh263KKcq0wBNOGFJbm+U6784RilTmHjB/HM9kH9V8WlCpVUcdOmip9cjXOh6MxZ5yk1z2SjDUJfWmA==", + "license": "MIT", + "dependencies": { + "@types/retry": "^0.12.0", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/qs": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.1.tgz", @@ -131,6 +210,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -143,6 +231,18 @@ "funding": { "url": "https://github.com/sponsors/ljharb" } + }, + "node_modules/tuyapi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-7.2.0.tgz", + "integrity": "sha512-o32MiO+6w8T9d5HwHA6+EwdNOAs2K13pgCHXK4kXeEAQSUdjbMU4Z3b3y+CHQh1ifxVDBQaU+iUeYPPq66ALFQ==", + "license": "MIT", + "dependencies": { + "debug": "4.1.1", + "p-queue": "6.6.1", + "p-retry": "4.2.0", + "p-timeout": "3.2.0" + } } } } diff --git a/server/services/tuya/package.json b/server/services/tuya/package.json index 5047ac17dc..6ecc3f55ef 100644 --- a/server/services/tuya/package.json +++ b/server/services/tuya/package.json @@ -13,6 +13,7 @@ "arm64" ], "dependencies": { - "@tuya/tuya-connector-nodejs": "^2.1.2" + "@tuya/tuya-connector-nodejs": "^2.1.2", + "tuyapi": "^7.2.0" } } diff --git a/server/test/services/tuya/index.test.js b/server/test/services/tuya/index.test.js index bf08d1e33b..4592ea51bf 100644 --- a/server/test/services/tuya/index.test.js +++ b/server/test/services/tuya/index.test.js @@ -5,10 +5,23 @@ const { STATUS } = require('../../../services/tuya/lib/utils/tuya.constants'); const { assert, fake } = sinon; +const TuyaHandler = require('../../../services/tuya/lib'); + const TuyaHandlerMock = sinon.stub(); -TuyaHandlerMock.prototype.init = fake.returns(null); +TuyaHandlerMock.prototype.init = fake(async function init() { + this.status = STATUS.CONNECTED; + await this.loadDevices(); + this.startReconnect(); +}); TuyaHandlerMock.prototype.loadDevices = fake.returns(null); -TuyaHandlerMock.prototype.disconnect = fake.returns(null); +TuyaHandlerMock.prototype.disconnect = fake(function disconnect() { + this.stopReconnect(); +}); +TuyaHandlerMock.prototype.startReconnect = TuyaHandler.prototype.startReconnect; +TuyaHandlerMock.prototype.stopReconnect = TuyaHandler.prototype.stopReconnect; +TuyaHandlerMock.prototype.tryReconnect = TuyaHandler.prototype.tryReconnect; +TuyaHandlerMock.prototype.scheduleQuickReconnects = TuyaHandler.prototype.scheduleQuickReconnects; +TuyaHandlerMock.prototype.clearQuickReconnects = TuyaHandler.prototype.clearQuickReconnects; const TuyaService = proxyquire('../../../services/tuya/index', { './lib': TuyaHandlerMock }); @@ -16,14 +29,29 @@ const gladys = {}; const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; describe('TuyaService', () => { - const tuyaService = TuyaService(gladys, serviceId); + let tuyaService; + let intervalCallback; + let setIntervalStub; + let clearIntervalStub; beforeEach(() => { - sinon.reset(); + sinon.resetHistory(); + intervalCallback = null; + setIntervalStub = sinon.stub(global, 'setInterval').callsFake((cb) => { + intervalCallback = cb; + return 123; + }); + clearIntervalStub = sinon.stub(global, 'clearInterval'); + tuyaService = TuyaService(gladys, serviceId); + tuyaService.device.reconnectInterval = null; + tuyaService.device.quickReconnectTimeouts = []; + tuyaService.device.quickReconnectInProgress = false; }); afterEach(() => { - sinon.reset(); + setIntervalStub.restore(); + clearIntervalStub.restore(); + sinon.resetHistory(); }); it('should start service', async () => { @@ -34,9 +62,10 @@ describe('TuyaService', () => { }); it('should stop service', async () => { - tuyaService.stop(); - assert.notCalled(tuyaService.device.init); + await tuyaService.start(); + await tuyaService.stop(); assert.calledOnce(tuyaService.device.disconnect); + assert.calledOnce(clearIntervalStub); }); it('isUsed: should return false, service not used', async () => { @@ -50,4 +79,150 @@ describe('TuyaService', () => { const used = await tuyaService.isUsed(); expect(used).to.equal(true); }); + + it('should attempt auto-reconnect when disconnected', async () => { + tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false }); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.ERROR; + tuyaService.device.autoReconnectAllowed = true; + + await intervalCallback(); + + assert.calledOnce(tuyaService.device.getStatus); + assert.calledOnce(tuyaService.device.getConfiguration); + assert.calledOnce(tuyaService.device.connect); + }); + + it('should not auto-reconnect when not configured or manually disconnected', async () => { + tuyaService.device.getStatus = fake.resolves({ configured: false, manual_disconnect: true }); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.ERROR; + tuyaService.device.autoReconnectAllowed = true; + + await intervalCallback(); + + assert.calledOnce(tuyaService.device.getStatus); + assert.notCalled(tuyaService.device.getConfiguration); + assert.notCalled(tuyaService.device.connect); + }); + + it('should not auto-reconnect when autoReconnectAllowed is false', async () => { + tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false }); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.ERROR; + tuyaService.device.autoReconnectAllowed = false; + + await intervalCallback(); + + assert.notCalled(tuyaService.device.getStatus); + assert.notCalled(tuyaService.device.getConfiguration); + assert.notCalled(tuyaService.device.connect); + }); + + it('should not auto-reconnect when already connecting', async () => { + tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false }); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.CONNECTING; + tuyaService.device.autoReconnectAllowed = true; + + await intervalCallback(); + + assert.calledOnce(tuyaService.device.getStatus); + assert.notCalled(tuyaService.device.getConfiguration); + assert.notCalled(tuyaService.device.connect); + }); + + it('should schedule quick reconnects on start when disconnected and allowed', async () => { + tuyaService.device.init = fake(function init() { + this.status = STATUS.ERROR; + this.autoReconnectAllowed = true; + this.startReconnect(); + }); + tuyaService.device.getStatus = fake.resolves({ configured: false, manual_disconnect: false }); + + await tuyaService.start(); + + assert.calledOnce(tuyaService.device.getStatus); + assert.notCalled(tuyaService.device.loadDevices); + }); + + it('should skip quick reconnect when already in progress', async () => { + let resolveStatus; + const pendingStatus = new Promise((resolve) => { + resolveStatus = resolve; + }); + + tuyaService.device.getStatus = fake.returns(pendingStatus); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.ERROR; + tuyaService.device.autoReconnectAllowed = true; + + const firstCall = intervalCallback(); + await intervalCallback(); + + assert.calledOnce(tuyaService.device.getStatus); + + resolveStatus({ configured: false, manual_disconnect: false }); + await firstCall; + }); + + it('should clear pending quick reconnect timeouts', async () => { + const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake(() => 456); + const clearTimeoutStub = sinon.stub(global, 'clearTimeout'); + + try { + tuyaService.device.init = fake(function init() { + this.status = STATUS.CONNECTED; + this.autoReconnectAllowed = true; + this.startReconnect(); + }); + tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false }); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.ERROR; + tuyaService.device.autoReconnectAllowed = true; + + await intervalCallback(); + await tuyaService.stop(); + + assert.calledOnce(setTimeoutStub); + assert.calledWith(clearTimeoutStub, 456); + } finally { + setTimeoutStub.restore(); + clearTimeoutStub.restore(); + } + }); + + it('should handle auto-reconnect errors', async () => { + tuyaService.device.getStatus = fake.rejects(new Error('status failure')); + tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' }); + tuyaService.device.connect = fake.resolves(); + + await tuyaService.start(); + tuyaService.device.status = STATUS.ERROR; + tuyaService.device.autoReconnectAllowed = true; + + await intervalCallback(); + + assert.calledOnce(tuyaService.device.getStatus); + assert.notCalled(tuyaService.device.getConfiguration); + assert.notCalled(tuyaService.device.connect); + }); }); diff --git a/server/test/services/tuya/lib/controllers/tuya.controller.test.js b/server/test/services/tuya/lib/controllers/tuya.controller.test.js index ee140346a4..9ddb962144 100644 --- a/server/test/services/tuya/lib/controllers/tuya.controller.test.js +++ b/server/test/services/tuya/lib/controllers/tuya.controller.test.js @@ -1,18 +1,32 @@ const sinon = require('sinon'); +const { expect } = require('chai'); const TuyaController = require('../../../../../services/tuya/api/tuya.controller'); const { assert, fake } = sinon; const tuyaManager = { discoverDevices: fake.resolves([]), + localPoll: fake.resolves({ dps: { 1: true } }), + localScan: fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} }), + getStatus: fake.resolves({ status: 'connected' }), + manualDisconnect: fake.resolves(), + discoveredDevices: [ + { + external_id: 'tuya:device1', + params: [], + }, + ], }; +const defaultLocalScan = tuyaManager.localScan; describe('TuyaController GET /api/v1/service/tuya/discover', () => { let controller; beforeEach(() => { controller = TuyaController(tuyaManager); - sinon.reset(); + sinon.resetHistory(); + tuyaManager.localScan = fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} }); + tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }]; }); it('should return discovered devices', async () => { @@ -26,3 +40,164 @@ describe('TuyaController GET /api/v1/service/tuya/discover', () => { assert.calledOnce(res.json); }); }); + +describe('TuyaController POST /api/v1/service/tuya/local-poll', () => { + let controller; + + beforeEach(() => { + controller = TuyaController(tuyaManager); + sinon.resetHistory(); + tuyaManager.localScan = fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} }); + tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }]; + }); + + it('should return local poll result', async () => { + const req = { + body: { deviceId: 'device1', ip: '1.1.1.1', localKey: 'key', protocolVersion: '3.3' }, + }; + const res = { + json: fake.returns([]), + }; + + await controller['post /api/v1/service/tuya/local-poll'].controller(req, res); + assert.calledOnce(tuyaManager.localPoll); + assert.calledOnce(res.json); + }); + + it('should return local poll result without updating device', async () => { + const req = { + body: { deviceId: 'unknown', ip: '1.1.1.1', localKey: 'key', protocolVersion: '3.3' }, + }; + const res = { + json: fake.returns([]), + }; + tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }]; + + await controller['post /api/v1/service/tuya/local-poll'].controller(req, res); + assert.calledOnce(tuyaManager.localPoll); + assert.calledWith(res.json, { dps: { 1: true } }); + }); + + it('should update existing params when device is found', async () => { + const req = { + body: { deviceId: 'device1', ip: '2.2.2.2', protocolVersion: '3.3' }, + }; + const res = { + json: fake.returns([]), + }; + tuyaManager.discoveredDevices = [ + { + external_id: 'tuya:device1', + product_id: 'pid', + product_key: 'pkey', + params: [{ name: 'IP_ADDRESS', value: '1.1.1.1' }], + }, + ]; + + await controller['post /api/v1/service/tuya/local-poll'].controller(req, res); + + const updated = tuyaManager.discoveredDevices[0]; + const ipParam = updated.params.find((param) => param.name === 'IP_ADDRESS'); + const localKeyParam = updated.params.find((param) => param.name === 'LOCAL_KEY'); + + expect(ipParam.value).to.equal('2.2.2.2'); + expect(localKeyParam).to.equal(undefined); + assert.calledOnce(res.json); + }); +}); + +describe('TuyaController POST /api/v1/service/tuya/local-scan', () => { + let controller; + + beforeEach(() => { + controller = TuyaController(tuyaManager); + sinon.resetHistory(); + tuyaManager.localScan = fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} }); + tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }]; + }); + + afterEach(() => { + tuyaManager.localScan = defaultLocalScan; + }); + + it('should run local scan and return devices', async () => { + const req = { body: { timeoutSeconds: 1 } }; + const res = { + json: fake.returns([]), + }; + + await controller['post /api/v1/service/tuya/local-scan'].controller(req, res); + assert.calledOnce(tuyaManager.localScan); + assert.calledOnce(res.json); + }); + + it('should return local devices even without discovered devices', async () => { + const req = { body: { timeoutSeconds: 1 } }; + const res = { + json: fake.returns([]), + }; + tuyaManager.discoveredDevices = null; + + await controller['post /api/v1/service/tuya/local-scan'].controller(req, res); + assert.calledOnce(tuyaManager.localScan); + assert.calledOnce(res.json); + const payload = res.json.firstCall.args[0]; + expect(payload.devices).to.have.length(1); + expect(payload.devices[0].external_id).to.equal('tuya:device1'); + expect(payload.local_devices).to.deep.equal({ device1: { ip: '1.1.1.1', version: '3.3' } }); + expect(payload.port_errors).to.deep.equal({}); + }); + + it('should keep devices unchanged when local info is missing', async () => { + const req = { body: { timeoutSeconds: 1 } }; + const res = { + json: fake.returns([]), + }; + tuyaManager.localScan = fake.resolves({ devices: {}, portErrors: {} }); + tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }]; + + await controller['post /api/v1/service/tuya/local-scan'].controller(req, res); + assert.calledOnce(tuyaManager.localScan); + assert.calledWith(res.json, { + devices: [{ external_id: 'tuya:device1', params: [] }], + local_devices: {}, + port_errors: {}, + }); + }); +}); + +describe('TuyaController GET /api/v1/service/tuya/status', () => { + let controller; + + beforeEach(() => { + controller = TuyaController(tuyaManager); + sinon.resetHistory(); + }); + + it('should return status', async () => { + const req = {}; + const res = { json: fake.returns([]) }; + + await controller['get /api/v1/service/tuya/status'].controller(req, res); + assert.calledOnce(tuyaManager.getStatus); + assert.calledWith(res.json, { status: 'connected' }); + }); +}); + +describe('TuyaController POST /api/v1/service/tuya/disconnect', () => { + let controller; + + beforeEach(() => { + controller = TuyaController(tuyaManager); + sinon.resetHistory(); + }); + + it('should disconnect', async () => { + const req = {}; + const res = { json: fake.returns([]) }; + + await controller['post /api/v1/service/tuya/disconnect'].controller(req, res); + assert.calledOnce(tuyaManager.manualDisconnect); + assert.calledWith(res.json, { success: true }); + }); +}); diff --git a/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js b/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js index bd39be94ab..cceed718d4 100644 --- a/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js +++ b/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js @@ -25,9 +25,9 @@ describe('Tuya convert feature', () => { has_feedback: false, max: 1000, min: 100, - name: 'name', + name: 'switch_1', read_only: false, - selector: 'externalId:switch_1', + selector: 'externalid-switch-1', type: 'binary', }); }); @@ -49,9 +49,9 @@ describe('Tuya convert feature', () => { has_feedback: false, max: 1, min: 0, - name: 'name', + name: 'switch_1', read_only: false, - selector: 'externalId:switch_1', + selector: 'externalid-switch-1', type: 'binary', }); }); diff --git a/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js b/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js index 9412c4720a..ad1c7a6c4f 100644 --- a/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js +++ b/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js @@ -74,6 +74,10 @@ describe('Tuya device mapping', () => { const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY](true); expect(result).to.eq(1); }); + it('switch string on', () => { + const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY]('ON'); + expect(result).to.eq(1); + }); it('energy', () => { const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.ENERGY]('30'); expect(result).to.eq(0.3); diff --git a/server/test/services/tuya/lib/device/tuya.convertDevice.test.js b/server/test/services/tuya/lib/device/tuya.convertDevice.test.js new file mode 100644 index 0000000000..ff9b155ea3 --- /dev/null +++ b/server/test/services/tuya/lib/device/tuya.convertDevice.test.js @@ -0,0 +1,75 @@ +const { expect } = require('chai'); + +const { convertDevice } = require('../../../../../services/tuya/lib/device/tuya.convertDevice'); +const { DEVICE_PARAM_NAME } = require('../../../../../services/tuya/lib/utils/tuya.constants'); + +describe('tuya.convertDevice', () => { + it('should map params and features with optional fields', () => { + const tuyaDevice = { + id: 'device-id', + name: 'Device', + product_name: 'Model', + product_id: 'product-id', + product_key: 'product-key', + local_key: 'local-key', + ip: '1.1.1.1', + cloud_ip: '2.2.2.2', + protocol_version: '3.3', + local_override: ' TRUE ', + is_online: true, + properties: { properties: [{ code: 'foo', value: 'bar' }] }, + thing_model: { services: [] }, + specifications: { + category: 'cz', + functions: [{ code: 'switch_1', name: 'Switch', type: 'Boolean' }], + status: [{ code: 'cur_power', name: 'Power', type: 'Integer' }], + }, + }; + + const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice); + + expect(device.product_id).to.equal('product-id'); + expect(device.product_key).to.equal('product-key'); + expect(device.online).to.equal(true); + expect(device.features.length).to.equal(2); + expect(device.properties).to.deep.equal({ properties: [{ code: 'foo', value: 'bar' }] }); + expect(device.thing_model).to.deep.equal({ services: [] }); + expect(device.specifications).to.deep.equal({ + category: 'cz', + functions: [{ code: 'switch_1', name: 'Switch', type: 'Boolean' }], + status: [{ code: 'cur_power', name: 'Power', type: 'Integer' }], + }); + + const params = device.params.reduce((acc, param) => { + acc[param.name] = param.value; + return acc; + }, {}); + + expect(params[DEVICE_PARAM_NAME.DEVICE_ID]).to.equal('device-id'); + expect(params[DEVICE_PARAM_NAME.LOCAL_KEY]).to.equal('local-key'); + expect(params[DEVICE_PARAM_NAME.IP_ADDRESS]).to.equal('1.1.1.1'); + expect(params[DEVICE_PARAM_NAME.CLOUD_IP]).to.equal('2.2.2.2'); + expect(params[DEVICE_PARAM_NAME.PROTOCOL_VERSION]).to.equal('3.3'); + expect(params[DEVICE_PARAM_NAME.LOCAL_OVERRIDE]).to.equal(true); + expect(params[DEVICE_PARAM_NAME.PRODUCT_ID]).to.equal('product-id'); + expect(params[DEVICE_PARAM_NAME.PRODUCT_KEY]).to.equal('product-key'); + }); + + it('should handle missing optional fields', () => { + const tuyaDevice = { + id: 'device-id', + name: 'Device', + model: 'Model', + online: false, + specifications: {}, + }; + + const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice); + + expect(device.product_id).to.equal(undefined); + expect(device.product_key).to.equal(undefined); + expect(device.online).to.equal(false); + expect(device.features.length).to.equal(0); + expect(device.specifications).to.deep.equal({}); + }); +}); diff --git a/server/test/services/tuya/lib/tuya.connect.test.js b/server/test/services/tuya/lib/tuya.connect.test.js index 6a3b646fa3..04307390c9 100644 --- a/server/test/services/tuya/lib/tuya.connect.test.js +++ b/server/test/services/tuya/lib/tuya.connect.test.js @@ -11,7 +11,7 @@ const connect = proxyquire('../../../../services/tuya/lib/tuya.connect', { const TuyaHandler = proxyquire('../../../../services/tuya/lib/index', { './tuya.connect.js': connect, }); -const { STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants'); +const { STATUS, GLADYS_VARIABLES } = require('../../../../services/tuya/lib/utils/tuya.constants'); const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); @@ -19,6 +19,9 @@ const gladys = { event: { emit: fake.returns(null), }, + variable: { + setValue: fake.resolves(null), + }, }; const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; @@ -49,6 +52,7 @@ describe('TuyaHandler.connect', () => { assert.notCalled(gladys.event.emit); assert.notCalled(client.init); + assert.notCalled(gladys.variable.setValue); }); it('no access key stored, should fail', async () => { @@ -66,6 +70,7 @@ describe('TuyaHandler.connect', () => { assert.notCalled(gladys.event.emit); assert.notCalled(client.init); + assert.notCalled(gladys.variable.setValue); }); it('no secret key stored, should fail', async () => { @@ -83,6 +88,7 @@ describe('TuyaHandler.connect', () => { assert.notCalled(gladys.event.emit); assert.notCalled(client.init); + assert.notCalled(gladys.variable.setValue); }); it('well connected', async () => { @@ -90,11 +96,20 @@ describe('TuyaHandler.connect', () => { baseUrl: 'apiUrl', accessKey: 'accessKey', secretKey: 'secretKey', + appAccountId: 'appAccountId', }); expect(tuyaHandler.status).to.eq(STATUS.CONNECTED); assert.calledOnce(client.init); + assert.calledTwice(gladys.variable.setValue); + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', serviceId); + assert.calledWith( + gladys.variable.setValue, + GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, + sinon.match.string, + serviceId, + ); assert.callCount(gladys.event.emit, 2); assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { @@ -103,7 +118,7 @@ describe('TuyaHandler.connect', () => { }); assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, - payload: { status: STATUS.CONNECTED }, + payload: { status: STATUS.CONNECTED, error: null }, }); }); @@ -114,20 +129,128 @@ describe('TuyaHandler.connect', () => { baseUrl: 'apiUrl', accessKey: 'accessKey', secretKey: 'secretKey', + appAccountId: 'appAccountId', }); expect(tuyaHandler.status).to.eq(STATUS.ERROR); assert.calledOnce(client.init); + assert.notCalled(gladys.variable.setValue); - assert.callCount(gladys.event.emit, 2); + assert.callCount(gladys.event.emit, 3); assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, payload: { status: STATUS.CONNECTING }, }); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR, + payload: { message: 'Error' }, + }); assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, - payload: { status: STATUS.ERROR }, + payload: { status: STATUS.ERROR, error: 'Error' }, + }); + }); + + it('should map invalid client id error', async () => { + client.init.rejects(new Error('GET_TOKEN_FAILED 2009, clientId is invalid')); + tuyaHandler.autoReconnectAllowed = true; + + await tuyaHandler.connect({ + baseUrl: 'apiUrl', + accessKey: 'accessKey', + secretKey: 'secretKey', + appAccountId: 'appAccountId', + }); + + expect(tuyaHandler.status).to.eq(STATUS.ERROR); + expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidClientId'); + expect(tuyaHandler.autoReconnectAllowed).to.equal(false); + }); + + it('should map invalid client secret error', async () => { + client.init.rejects(new Error('GET_TOKEN_FAILED 1004, sign invalid')); + tuyaHandler.autoReconnectAllowed = true; + + await tuyaHandler.connect({ + baseUrl: 'apiUrl', + accessKey: 'accessKey', + secretKey: 'secretKey', + appAccountId: 'appAccountId', }); + + expect(tuyaHandler.status).to.eq(STATUS.ERROR); + expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidClientSecret'); + expect(tuyaHandler.autoReconnectAllowed).to.equal(false); + }); + + it('should map invalid endpoint error', async () => { + client.init.rejects(new Error('No permission. The data center is suspended.')); + tuyaHandler.autoReconnectAllowed = true; + + await tuyaHandler.connect({ + baseUrl: 'apiUrl', + accessKey: 'accessKey', + secretKey: 'secretKey', + appAccountId: 'appAccountId', + }); + + expect(tuyaHandler.status).to.eq(STATUS.ERROR); + expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidEndpoint'); + expect(tuyaHandler.autoReconnectAllowed).to.equal(false); + }); + + it('should reject missing app account uid before connecting', async () => { + tuyaHandler.autoReconnectAllowed = true; + + try { + await tuyaHandler.connect({ + baseUrl: 'apiUrl', + accessKey: 'accessKey', + secretKey: 'secretKey', + appAccountId: '', + }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.message).to.eq('Tuya is not configured.'); + } + + expect(tuyaHandler.status).to.eq(STATUS.NOT_INITIALIZED); + }); + + it('should map invalid app account uid from api response', async () => { + const clientStub = { + init: sinon.stub().resolves(), + }; + const requestStub = sinon.stub().resolves({ + success: false, + msg: 'permission deny', + code: 1106, + }); + const TuyaContextStub = function TuyaContextStub() { + this.client = clientStub; + this.request = requestStub; + }; + + const connectWithStub = proxyquire('../../../../services/tuya/lib/tuya.connect', { + '@tuya/tuya-connector-nodejs': { TuyaContext: TuyaContextStub }, + }); + const TuyaHandlerWithStub = proxyquire('../../../../services/tuya/lib/index', { + './tuya.connect.js': connectWithStub, + }); + const handler = new TuyaHandlerWithStub(gladys, serviceId); + handler.autoReconnectAllowed = true; + + await handler.connect({ + baseUrl: 'apiUrl', + accessKey: 'accessKey', + secretKey: 'secretKey', + appAccountId: 'appAccountId', + }); + + expect(handler.status).to.eq(STATUS.ERROR); + expect(handler.lastError).to.eq('integration.tuya.setup.errorInvalidAppAccountUid'); + expect(handler.autoReconnectAllowed).to.equal(false); + assert.calledOnce(requestStub); }); }); diff --git a/server/test/services/tuya/lib/tuya.disconnect.test.js b/server/test/services/tuya/lib/tuya.disconnect.test.js index b6731986ff..0d8ce679bb 100644 --- a/server/test/services/tuya/lib/tuya.disconnect.test.js +++ b/server/test/services/tuya/lib/tuya.disconnect.test.js @@ -1,9 +1,15 @@ const { expect } = require('chai'); +const sinon = require('sinon'); const TuyaHandler = require('../../../../services/tuya/lib/index'); const { STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); -const gladys = {}; +const gladys = { + event: { + emit: sinon.fake.returns(null), + }, +}; const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; describe('TuyaHandler.disconnect', () => { @@ -11,6 +17,8 @@ describe('TuyaHandler.disconnect', () => { beforeEach(() => { tuyaHandler.status = 'UNKNOWN'; + tuyaHandler.lastError = 'previous-error'; + gladys.event.emit.resetHistory(); }); it('should reset attributes', () => { @@ -18,5 +26,19 @@ describe('TuyaHandler.disconnect', () => { expect(tuyaHandler.status).to.eq(STATUS.NOT_INITIALIZED); expect(tuyaHandler.connector).to.eq(null); + expect(tuyaHandler.lastError).to.eq(null); + sinon.assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, + payload: { status: STATUS.NOT_INITIALIZED, manual_disconnect: false }, + }); + }); + + it('should send manual disconnect status', () => { + tuyaHandler.disconnect({ manual: true }); + + sinon.assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, + payload: { status: STATUS.NOT_INITIALIZED, manual_disconnect: true }, + }); }); }); diff --git a/server/test/services/tuya/lib/tuya.discoverDevices.test.js b/server/test/services/tuya/lib/tuya.discoverDevices.test.js index 7c537b429d..47fa55408a 100644 --- a/server/test/services/tuya/lib/tuya.discoverDevices.test.js +++ b/server/test/services/tuya/lib/tuya.discoverDevices.test.js @@ -18,6 +18,9 @@ const gladys = { variable: { getValue: fake.resolves('APP_ACCOUNT_UID'), }, + device: { + get: fake.resolves([]), + }, }; const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; @@ -26,13 +29,28 @@ describe('TuyaHandler.discoverDevices', () => { beforeEach(() => { sinon.reset(); + gladys.event.emit = fake.resolves(null); + gladys.stateManager.get = fake.returns(null); + gladys.variable.getValue = fake.resolves('APP_ACCOUNT_UID'); + gladys.device.get = fake.resolves([]); tuyaHandler.status = STATUS.CONNECTED; tuyaHandler.connector = { request: sinon .stub() - .onFirstCall() - .resolves({ result: { list: [{ name: 'name', id: 'uuid', product_name: 'model' }] } }) - .onSecondCall() + .onCall(0) + .resolves({ + result: [ + { + name: 'name', + id: 'uuid', + product_name: 'model', + local_key: 'localKey', + ip: '1.1.1.1', + online: true, + }, + ], + }) + .onCall(1) .resolves({ result: { details: 'details', @@ -50,6 +68,26 @@ describe('TuyaHandler.discoverDevices', () => { type: 'Integer', }, ], + category: 'cz', + }, + }) + .onCall(2) + .resolves({ + result: { + local_key: 'localKey', + ip: '1.1.1.1', + }, + }) + .onCall(3) + .resolves({ + result: { + properties: [{ code: 'switch_1', value: true }], + }, + }) + .onCall(4) + .resolves({ + result: { + model: '{"services":[]}', }, }), }; @@ -111,7 +149,7 @@ describe('TuyaHandler.discoverDevices', () => { min: 0, name: 'cur_power', read_only: true, - selector: 'tuya:uuid:cur_power', + selector: 'tuya-uuid-cur-power', type: 'power', unit: 'watt', }, @@ -121,18 +159,63 @@ describe('TuyaHandler.discoverDevices', () => { has_feedback: false, max: 1, min: 0, - name: 'name', + name: 'switch_1', read_only: false, - selector: 'tuya:uuid:switch_1', + selector: 'tuya-uuid-switch-1', type: 'binary', }, ], model: 'model', name: 'name', poll_frequency: 30000, - selector: 'tuya:uuid', + params: [ + { + name: 'DEVICE_ID', + value: 'uuid', + }, + { + name: 'LOCAL_KEY', + value: 'localKey', + }, + { + name: 'CLOUD_IP', + value: '1.1.1.1', + }, + { + name: 'LOCAL_OVERRIDE', + value: false, + }, + ], + properties: { + properties: [{ code: 'switch_1', value: true }], + }, + product_id: undefined, + product_key: undefined, + specifications: { + details: 'details', + category: 'cz', + functions: [ + { + code: 'switch_1', + name: 'name', + type: 'Boolean', + }, + ], + status: [ + { + code: 'cur_power', + name: 'cur_power', + type: 'Integer', + }, + ], + }, + selector: 'tuya-uuid', service_id: 'ffa13430-df93-488a-9733-5c540e9558e0', should_poll: true, + thing_model: { + services: [], + }, + online: true, }, ]); @@ -146,6 +229,47 @@ describe('TuyaHandler.discoverDevices', () => { payload: { status: STATUS.CONNECTED }, }); - assert.calledTwice(tuyaHandler.connector.request); + assert.callCount(tuyaHandler.connector.request, 5); + }); + + it('should keep local params from existing devices', async () => { + gladys.stateManager.get = fake.returns({ + external_id: 'tuya:uuid', + params: [ + { name: 'IP_ADDRESS', value: '2.2.2.2' }, + { name: 'PROTOCOL_VERSION', value: '3.3' }, + { name: 'LOCAL_OVERRIDE', value: true }, + ], + features: [{ external_id: 'tuya:uuid:cur_power' }, { external_id: 'tuya:uuid:switch_1' }], + }); + + const devices = await tuyaHandler.discoverDevices(); + const { params } = devices[0]; + const getParam = (name) => params.find((param) => param.name === name); + + expect(getParam('IP_ADDRESS').value).to.equal('2.2.2.2'); + expect(getParam('PROTOCOL_VERSION').value).to.equal('3.3'); + expect(getParam('LOCAL_OVERRIDE').value).to.equal(true); + }); + + it('should append existing devices not returned by discovery', async () => { + gladys.device.get = fake.resolves([ + { external_id: 'tuya:existing', name: 'Existing device', params: [] }, + { name: 'missing external id' }, + ]); + + const devices = await tuyaHandler.discoverDevices(); + const existing = devices.find((device) => device.external_id === 'tuya:existing'); + + expect(existing).to.not.equal(undefined); + expect(existing.updatable).to.equal(false); + }); + + it('should continue when loading existing devices fails', async () => { + gladys.device.get = fake.rejects(new Error('failure')); + + const devices = await tuyaHandler.discoverDevices(); + expect(devices).to.be.an('array'); + expect(devices.length).to.be.greaterThan(0); }); }); diff --git a/server/test/services/tuya/lib/tuya.getConfiguration.test.js b/server/test/services/tuya/lib/tuya.getConfiguration.test.js index fad9a7c485..cea8773a18 100644 --- a/server/test/services/tuya/lib/tuya.getConfiguration.test.js +++ b/server/test/services/tuya/lib/tuya.getConfiguration.test.js @@ -31,7 +31,11 @@ describe('TuyaHandler.getConfiguration', () => { .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId) .returns('accessKey') .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId) - .returns('secretKey'); + .returns('secretKey') + .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId) + .returns('appAccountId') + .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId) + .returns('user@example.com'); const config = await tuyaHandler.getConfiguration(); @@ -39,11 +43,16 @@ describe('TuyaHandler.getConfiguration', () => { baseUrl: 'https://openapi-ueaz.tuyaus.com', accessKey: 'accessKey', secretKey: 'secretKey', + appUsername: 'user@example.com', + endpoint: 'easternAmerica', + appAccountId: 'appAccountId', }); - assert.callCount(gladys.variable.getValue, 3); + assert.callCount(gladys.variable.getValue, 5); assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ENDPOINT, serviceId); assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ACCESS_KEY, serviceId); assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.SECRET_KEY, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_USERNAME, serviceId); }); }); diff --git a/server/test/services/tuya/lib/tuya.getStatus.test.js b/server/test/services/tuya/lib/tuya.getStatus.test.js new file mode 100644 index 0000000000..2d5c2961c9 --- /dev/null +++ b/server/test/services/tuya/lib/tuya.getStatus.test.js @@ -0,0 +1,86 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert } = sinon; + +const TuyaHandler = require('../../../../services/tuya/lib/index'); +const { GLADYS_VARIABLES, STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants'); + +const gladys = { + variable: { + getValue: sinon.stub(), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('TuyaHandler.getStatus', () => { + const tuyaHandler = new TuyaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should return configured=false when credentials are missing', async () => { + gladys.variable.getValue + .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId) + .returns('accessKey') + .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId) + .returns('secretKey') + .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId) + .returns('appAccountId') + .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId) + .returns(null); + + tuyaHandler.status = STATUS.NOT_INITIALIZED; + tuyaHandler.lastError = null; + + const status = await tuyaHandler.getStatus(); + + expect(status).to.deep.eq({ + status: STATUS.NOT_INITIALIZED, + connected: false, + configured: false, + error: null, + manual_disconnect: false, + }); + + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ENDPOINT, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ACCESS_KEY, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.SECRET_KEY, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId); + }); + + it('should return manual_disconnect=true when stored as string', async () => { + gladys.variable.getValue + .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId) + .returns('endpoint') + .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId) + .returns('accessKey') + .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId) + .returns('secretKey') + .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId) + .returns('appAccountId') + .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId) + .returns('1'); + + tuyaHandler.status = STATUS.CONNECTED; + tuyaHandler.lastError = 'nope'; + + const status = await tuyaHandler.getStatus(); + + expect(status).to.deep.eq({ + status: STATUS.CONNECTED, + connected: true, + configured: true, + error: 'nope', + manual_disconnect: true, + }); + }); +}); diff --git a/server/test/services/tuya/lib/tuya.init.test.js b/server/test/services/tuya/lib/tuya.init.test.js index dbd7cdb01d..a15a88f48a 100644 --- a/server/test/services/tuya/lib/tuya.init.test.js +++ b/server/test/services/tuya/lib/tuya.init.test.js @@ -17,6 +17,7 @@ const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants const gladys = { variable: { getValue: sinon.stub(), + setValue: sinon.stub().resolves(null), }, event: { emit: fake.returns(null), @@ -30,6 +31,8 @@ describe('TuyaHandler.init', () => { beforeEach(() => { sinon.reset(); tuyaHandler.status = 'UNKNOWN'; + tuyaHandler.loadDevices = sinon.stub().resolves([]); + tuyaHandler.startReconnect = sinon.stub(); }); afterEach(() => { @@ -43,16 +46,28 @@ describe('TuyaHandler.init', () => { .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId) .returns('accessKey') .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId) - .returns('secretKey'); + .returns('secretKey') + .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId) + .returns('appAccountId') + .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId) + .returns('appUsername') + .withArgs(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId) + .returns(null); await tuyaHandler.init(); expect(tuyaHandler.status).to.eq(STATUS.CONNECTED); - assert.callCount(gladys.variable.getValue, 3); + assert.callCount(gladys.variable.getValue, 7); assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ENDPOINT, serviceId); assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ACCESS_KEY, serviceId); assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.SECRET_KEY, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_USERNAME, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId); + assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId); assert.calledOnce(client.init); @@ -63,7 +78,68 @@ describe('TuyaHandler.init', () => { }); assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, - payload: { status: STATUS.CONNECTED }, + payload: { status: STATUS.CONNECTED, error: null }, + }); + + assert.calledOnce(tuyaHandler.loadDevices); + assert.calledOnce(tuyaHandler.startReconnect); + }); + + it('should throw ServiceNotConfiguredError when not configured', async () => { + gladys.variable.getValue + .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId) + .returns(null); + + try { + await tuyaHandler.init(); + expect.fail('should have thrown'); + } catch (e) { + expect(e.message).to.eq('Tuya is not configured.'); + } + + expect(tuyaHandler.status).to.eq(STATUS.NOT_INITIALIZED); + expect(tuyaHandler.autoReconnectAllowed).to.equal(false); + assert.notCalled(client.init); + assert.notCalled(gladys.event.emit); + assert.notCalled(tuyaHandler.loadDevices); + assert.notCalled(tuyaHandler.startReconnect); + }); + + it('should not connect when manual disconnect is enabled', async () => { + gladys.variable.getValue + .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId) + .returns('apiUrl') + .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId) + .returns('accessKey') + .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId) + .returns('secretKey') + .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId) + .returns('appAccountId') + .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId) + .returns('appUsername') + .withArgs(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId) + .returns(null) + .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId) + .returns('1'); + + await tuyaHandler.init(); + + expect(tuyaHandler.status).to.eq(STATUS.NOT_INITIALIZED); + + assert.notCalled(client.init); + assert.notCalled(tuyaHandler.loadDevices); + assert.notCalled(tuyaHandler.startReconnect); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, + payload: { status: STATUS.NOT_INITIALIZED, manual_disconnect: true }, }); }); }); diff --git a/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js b/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js index e3b024b6be..5b6e282621 100644 --- a/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js +++ b/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js @@ -1,10 +1,11 @@ const { expect } = require('chai'); const sinon = require('sinon'); -const { assert, fake } = sinon; +const { assert } = sinon; const TuyaHandler = require('../../../../services/tuya/lib/index'); const { API } = require('../../../../services/tuya/lib/utils/tuya.constants'); +const logger = require('../../../../utils/logger'); const gladys = {}; const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; @@ -15,23 +16,194 @@ describe('TuyaHandler.loadDeviceDetails', () => { beforeEach(() => { sinon.reset(); tuyaHandler.connector = { - request: fake.resolves({ result: { details: 'details' } }), + request: sinon + .stub() + .onCall(0) + .resolves({ result: { details: 'specification' } }) + .onCall(1) + .resolves({ result: { local_key: 'localKey' } }) + .onCall(2) + .resolves({ result: { dps: { 1: true } } }) + .onCall(3) + .resolves({ result: { model: '{"services":[]}' } }), }; }); afterEach(() => { sinon.reset(); + if (logger.warn.restore) { + logger.warn.restore(); + } }); it('should load device details', async () => { const devices = await tuyaHandler.loadDeviceDetails({ id: 1 }); - expect(devices).to.deep.eq({ id: 1, specifications: { details: 'details' } }); + expect(devices).to.deep.eq({ + id: 1, + local_key: 'localKey', + specifications: { details: 'specification' }, + properties: { dps: { 1: true } }, + thing_model: { services: [] }, + }); - assert.callCount(tuyaHandler.connector.request, 1); + assert.callCount(tuyaHandler.connector.request, 4); assert.calledWith(tuyaHandler.connector.request, { method: 'GET', path: `${API.VERSION_1_2}/devices/1/specification`, }); + assert.calledWith(tuyaHandler.connector.request, { + method: 'GET', + path: `${API.VERSION_1_0}/devices/1`, + }); + assert.calledWith(tuyaHandler.connector.request, { + method: 'GET', + path: `${API.VERSION_2_0}/thing/1/shadow/properties`, + }); + assert.calledWith(tuyaHandler.connector.request, { + method: 'GET', + path: `${API.VERSION_2_0}/thing/1/model`, + }); + }); + + it('should preserve category in specifications when only available in details', async () => { + tuyaHandler.connector.request = sinon + .stub() + .onCall(0) + .resolves({ result: { functions: [], status: [] } }) + .onCall(1) + .resolves({ result: { local_key: 'localKey', category: 'cz' } }) + .onCall(2) + .resolves({ result: { dps: { 1: true } } }) + .onCall(3) + .resolves({ result: { model: '{"services":[]}' } }); + + const device = await tuyaHandler.loadDeviceDetails({ id: 1 }); + + expect(device.specifications.category).to.equal('cz'); + }); + + it('should warn when specifications loading fails', async () => { + const warnStub = sinon.stub(logger, 'warn'); + tuyaHandler.connector.request = sinon + .stub() + .onCall(0) + .rejects(new Error('spec failure')) + .onCall(1) + .resolves({ result: { local_key: 'localKey' } }) + .onCall(2) + .resolves({ result: { dps: { 1: true } } }) + .onCall(3) + .resolves({ result: { model: '{"services":[]}' } }); + + const device = await tuyaHandler.loadDeviceDetails({ id: 1 }); + + expect(device).to.deep.eq({ + id: 1, + local_key: 'localKey', + specifications: {}, + properties: { dps: { 1: true } }, + thing_model: { services: [] }, + }); + expect(warnStub.calledOnce).to.equal(true); + expect(warnStub.firstCall.args[0]).to.match(/Failed to load specifications/); + }); + + it('should warn when details loading fails', async () => { + const warnStub = sinon.stub(logger, 'warn'); + tuyaHandler.connector.request = sinon + .stub() + .onCall(0) + .resolves({ result: { details: 'specification' } }) + .onCall(1) + .rejects(new Error('details failure')) + .onCall(2) + .resolves({ result: { dps: { 1: true } } }) + .onCall(3) + .resolves({ result: { model: '{"services":[]}' } }); + + const device = await tuyaHandler.loadDeviceDetails({ id: 1 }); + + expect(device).to.deep.eq({ + id: 1, + specifications: { details: 'specification' }, + properties: { dps: { 1: true } }, + thing_model: { services: [] }, + }); + expect(warnStub.calledOnce).to.equal(true); + expect(warnStub.firstCall.args[0]).to.match(/Failed to load details/); + }); + + it('should warn when properties and model loading fails', async () => { + const warnStub = sinon.stub(logger, 'warn'); + tuyaHandler.connector.request = sinon + .stub() + .onCall(0) + .resolves({ result: { details: 'specification' } }) + .onCall(1) + .resolves({ result: { local_key: 'localKey' } }) + .onCall(2) + .rejects(new Error('props failure')) + .onCall(3) + .rejects(new Error('model failure')); + + const device = await tuyaHandler.loadDeviceDetails({ id: 1 }); + + expect(device).to.deep.eq({ + id: 1, + local_key: 'localKey', + specifications: { details: 'specification' }, + properties: {}, + thing_model: null, + }); + expect(warnStub.callCount).to.equal(2); + expect(warnStub.firstCall.args[0]).to.match(/Failed to load properties/); + expect(warnStub.secondCall.args[0]).to.match(/Failed to load thing model/); + }); + + it('should handle invalid thing model json', async () => { + tuyaHandler.connector.request = sinon + .stub() + .onCall(0) + .resolves({ result: { details: 'specification' } }) + .onCall(1) + .resolves({ result: { local_key: 'localKey' } }) + .onCall(2) + .resolves({ result: { dps: { 1: true } } }) + .onCall(3) + .resolves({ result: { model: 'not-json' } }); + + const device = await tuyaHandler.loadDeviceDetails({ id: 1 }); + + expect(device).to.deep.eq({ + id: 1, + local_key: 'localKey', + specifications: { details: 'specification' }, + properties: { dps: { 1: true } }, + thing_model: null, + }); + }); + + it('should keep thing model when model is an object', async () => { + tuyaHandler.connector.request = sinon + .stub() + .onCall(0) + .resolves({ result: { details: 'specification' } }) + .onCall(1) + .resolves({ result: { local_key: 'localKey' } }) + .onCall(2) + .resolves({ result: { dps: { 1: true } } }) + .onCall(3) + .resolves({ result: { services: [] } }); + + const device = await tuyaHandler.loadDeviceDetails({ id: 1 }); + + expect(device).to.deep.eq({ + id: 1, + local_key: 'localKey', + specifications: { details: 'specification' }, + properties: { dps: { 1: true } }, + thing_model: { services: [] }, + }); }); }); diff --git a/server/test/services/tuya/lib/tuya.loadDevices.test.js b/server/test/services/tuya/lib/tuya.loadDevices.test.js index 2ee6cd9d84..80d9c15d27 100644 --- a/server/test/services/tuya/lib/tuya.loadDevices.test.js +++ b/server/test/services/tuya/lib/tuya.loadDevices.test.js @@ -18,13 +18,14 @@ describe('TuyaHandler.loadDevices', () => { beforeEach(() => { sinon.reset(); + gladys.variable.getValue = fake.resolves('APP_ACCOUNT_UID'); tuyaHandler.connector = { request: sinon .stub() .onFirstCall() - .resolves({ result: { list: [{ id: 1 }], total: 2, has_more: true, last_row_key: 'next' } }) + .resolves({ result: { list: [{ id: 1 }], has_more: true } }) .onSecondCall() - .resolves({ result: { list: [{ id: 2 }], total: 2, has_more: false } }), + .resolves({ result: { list: [{ id: 2 }], has_more: false } }), }; }); @@ -33,20 +34,101 @@ describe('TuyaHandler.loadDevices', () => { }); it('should loop on pages', async () => { - const devices = await tuyaHandler.loadDevices(); + const devices = await tuyaHandler.loadDevices(1, 1); expect(devices).to.deep.eq([{ id: 1 }, { id: 2 }]); assert.callCount(tuyaHandler.connector.request, 2); assert.calledWith(tuyaHandler.connector.request, { method: 'GET', - path: `${API.VERSION_1_3}/devices`, - query: { last_row_key: null, source_id: 'APP_ACCOUNT_UID', source_type: 'tuyaUser' }, + path: `${API.PUBLIC_VERSION_1_0}/users/APP_ACCOUNT_UID/devices`, + query: { page_no: 1, page_size: 1 }, }); assert.calledWith(tuyaHandler.connector.request, { method: 'GET', - path: `${API.VERSION_1_3}/devices`, - query: { last_row_key: 'next', source_id: 'APP_ACCOUNT_UID', source_type: 'tuyaUser' }, + path: `${API.PUBLIC_VERSION_1_0}/users/APP_ACCOUNT_UID/devices`, + query: { page_no: 2, page_size: 1 }, }); }); + + it('should loop on pages with array result', async () => { + tuyaHandler.connector.request = sinon + .stub() + .onFirstCall() + .resolves({ result: [{ id: 1 }] }) + .onSecondCall() + .resolves({ result: [] }); + + const devices = await tuyaHandler.loadDevices(1, 1); + + expect(devices).to.deep.eq([{ id: 1 }]); + assert.callCount(tuyaHandler.connector.request, 2); + }); + + it('should throw on invalid pageNo', async () => { + try { + await tuyaHandler.loadDevices(0, 1); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('pageNo must be a positive integer'); + } + }); + + it('should throw on invalid pageSize', async () => { + try { + await tuyaHandler.loadDevices(1, 0); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('pageSize must be a positive integer'); + } + }); + + it('should throw on api error response', async () => { + tuyaHandler.connector.request = sinon.stub().resolves({ + success: false, + msg: 'Tuya error', + }); + + try { + await tuyaHandler.loadDevices(1, 1); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('Tuya error'); + } + }); + + it('should throw on empty api response', async () => { + tuyaHandler.connector.request = sinon.stub().resolves(null); + + try { + await tuyaHandler.loadDevices(1, 1); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('Tuya API returned no response'); + } + }); + + it('should throw when app account uid is missing', async () => { + gladys.variable.getValue = sinon.fake.resolves(null); + + try { + await tuyaHandler.loadDevices(1, 1); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('Tuya APP_ACCOUNT_UID is missing'); + } + }); + + it('should throw when pagination does not advance', async () => { + tuyaHandler.connector.request = sinon.stub().resolves({ + result: { list: [], has_more: true }, + }); + + try { + await tuyaHandler.loadDevices(1, 1); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('Tuya API pagination did not advance (has_more=true with empty page)'); + } + }); }); diff --git a/server/test/services/tuya/lib/tuya.localPoll.test.js b/server/test/services/tuya/lib/tuya.localPoll.test.js new file mode 100644 index 0000000000..768841371f --- /dev/null +++ b/server/test/services/tuya/lib/tuya.localPoll.test.js @@ -0,0 +1,357 @@ +/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire').noCallThru(); +const { BadParameters } = require('../../../../utils/coreErrors'); +const { DEVICE_PARAM_NAME } = require('../../../../services/tuya/lib/utils/tuya.constants'); +const { updateDiscoveredDeviceAfterLocalPoll } = require('../../../../services/tuya/lib/tuya.localPoll'); + +const attachEventHandlers = (instance) => { + instance.on = sinon.stub(); + instance.once = sinon.stub(); + instance.removeListener = sinon.stub(); +}; + +describe('TuyaHandler.localPoll', () => { + it('should throw if missing parameters', async () => { + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: function TuyAPIStub() {}, + }); + try { + await localPoll({}); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal('Missing local connection parameters'); + return; + } + throw new Error('Expected error'); + }); + + it('should return dps on success', async () => { + const connect = sinon.stub().resolves(); + const get = sinon.stub().resolves({ dps: { 1: true } }); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + attachEventHandlers(this); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + const result = await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + }); + expect(result).to.deep.equal({ dps: { 1: true } }); + expect(connect.calledOnce).to.equal(true); + expect(get.calledOnce).to.equal(true); + expect(disconnect.calledOnce).to.equal(true); + }); + + it('should throw on invalid response', async () => { + const connect = sinon.stub().resolves(); + const get = sinon.stub().resolves('parse data error'); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + attachEventHandlers(this); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + try { + await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + }); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.include('Invalid local poll response'); + return; + } + throw new Error('Expected error'); + }); + + it('should try multiple attempts for protocol 3.5', async () => { + const connect = sinon.stub().resolves(); + const get = sinon + .stub() + .onFirstCall() + .rejects(new Error('fail')) + .onSecondCall() + .resolves({ dps: { 1: true } }); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + attachEventHandlers(this); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + const result = await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.5', + timeoutMs: 1000, + }); + expect(result).to.deep.equal({ dps: { 1: true } }); + expect(get.calledTwice).to.equal(true); + }); + + it('should throw on object without dps', async () => { + const connect = sinon.stub().resolves(); + const get = sinon.stub().resolves({ ok: true }); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + attachEventHandlers(this); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + try { + await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + }); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal('Invalid local poll response'); + return; + } + throw new Error('Expected error'); + }); + + it('should timeout', async () => { + const clock = sinon.useFakeTimers(); + try { + const connect = sinon.stub().resolves(); + const get = sinon.stub().returns(new Promise(() => {})); + const disconnect = sinon.stub().resolves(); + const TuyAPIStub = function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + attachEventHandlers(this); + }; + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + const promise = localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + timeoutMs: 1000, + }); + // Attach handler immediately to avoid PromiseRejectionHandledWarning with fake timers. + const errorPromise = (async () => { + try { + await promise; + return null; + } catch (error) { + return error; + } + })(); + await clock.tickAsync(1100); + const error = await errorPromise; + expect(error).to.be.instanceOf(BadParameters); + expect(error.message).to.equal('Local poll timeout'); + } finally { + clock.restore(); + } + }); + + it('should reject on socket error listener', async () => { + const connect = sinon.stub().resolves(); + const get = sinon.stub().returns(new Promise(() => {})); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + this.on = sinon.stub(); + this.once = sinon.stub().callsFake((event, cb) => { + if (event === 'error') { + cb(new Error('boom')); + } + }); + this.removeListener = sinon.stub(); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + + try { + await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + }); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.include('Local poll socket error'); + return; + } + throw new Error('Expected error'); + }); + + it('should log last socket error when different from thrown error', async () => { + const connect = sinon.stub().rejects(new Error('connect failed')); + const get = sinon.stub(); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + this.once = sinon.stub(); + this.removeListener = sinon.stub(); + this.on = sinon.stub().callsFake((event, cb) => { + if (event === 'error') { + cb(new Error('socket boom')); + } + }); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + + try { + await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + }); + } catch (e) { + expect(e.message).to.equal('connect failed'); + return; + } + throw new Error('Expected error'); + }); + + it('should stop cleanup when already resolved', async () => { + const connect = sinon.stub().resolves(); + const get = sinon.stub().resolves({ dps: { 1: true } }); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.get = get; + this.disconnect = disconnect; + this.on = sinon.stub(); + this.once = sinon.stub(); + this.removeListener = sinon.stub().throws(new Error('removeListener error')); + } + const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', { + tuyapi: TuyAPIStub, + }); + + try { + await localPoll({ + deviceId: 'device', + ip: '1.1.1.1', + localKey: 'key', + protocolVersion: '3.3', + }); + } catch (e) { + expect(e.message).to.equal('removeListener error'); + return; + } + throw new Error('Expected error'); + }); +}); + +describe('TuyaHandler.updateDiscoveredDeviceAfterLocalPoll', () => { + it('should return null when payload is missing deviceId', () => { + const result = updateDiscoveredDeviceAfterLocalPoll({ discoveredDevices: [] }, {}); + expect(result).to.equal(null); + }); + + it('should return null when discovered devices is not an array', () => { + const result = updateDiscoveredDeviceAfterLocalPoll({ discoveredDevices: null }, { deviceId: 'device' }); + expect(result).to.equal(null); + }); + + it('should return null when device is not found', () => { + const tuyaManager = { discoveredDevices: [{ external_id: 'tuya:other' }] }; + const result = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, { deviceId: 'device' }); + expect(result).to.equal(null); + }); + + it('should update discovered device with local poll data', () => { + const tuyaManager = { + discoveredDevices: [ + { + external_id: 'tuya:device1', + params: [], + product_id: 'pid', + product_key: 'pkey', + }, + ], + }; + + const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, { + deviceId: 'device1', + ip: '1.1.1.1', + protocolVersion: '3.3', + localKey: 'key', + }); + + expect(updated.local_override).to.equal(true); + expect(updated.ip).to.equal('1.1.1.1'); + const { params } = updated; + const findParam = (name) => params.find((param) => param.name === name); + expect(findParam(DEVICE_PARAM_NAME.IP_ADDRESS).value).to.equal('1.1.1.1'); + expect(findParam(DEVICE_PARAM_NAME.PROTOCOL_VERSION).value).to.equal('3.3'); + expect(findParam(DEVICE_PARAM_NAME.LOCAL_KEY).value).to.equal('key'); + expect(findParam(DEVICE_PARAM_NAME.LOCAL_OVERRIDE).value).to.equal(true); + expect(findParam(DEVICE_PARAM_NAME.PRODUCT_ID).value).to.equal('pid'); + expect(findParam(DEVICE_PARAM_NAME.PRODUCT_KEY).value).to.equal('pkey'); + }); + + it('should merge when gladys stateManager exists', () => { + const tuyaManager = { + discoveredDevices: [ + { + external_id: 'tuya:device1', + params: [], + features: [], + }, + ], + gladys: { + stateManager: { + get: sinon.stub().returns({ + external_id: 'tuya:device1', + params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }], + features: [], + }), + }, + }, + }; + + const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, { + deviceId: 'device1', + ip: '1.1.1.1', + protocolVersion: '3.3', + }); + + expect(updated).to.have.property('updatable'); + expect(updated.local_override).to.equal(true); + }); +}); diff --git a/server/test/services/tuya/lib/tuya.localScan.test.js b/server/test/services/tuya/lib/tuya.localScan.test.js new file mode 100644 index 0000000000..0a5464d882 --- /dev/null +++ b/server/test/services/tuya/lib/tuya.localScan.test.js @@ -0,0 +1,324 @@ +/* eslint-disable require-jsdoc, jsdoc/require-jsdoc, class-methods-use-this */ +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire').noCallThru(); + +describe('TuyaHandler.localScan', () => { + it('should return discovered devices from udp payload', async () => { + const sockets = []; + const dgramStub = { + createSocket: () => { + const handlers = {}; + const socket = { + on: (event, cb) => { + handlers[event] = cb; + }, + bind: (options, cb) => { + if (typeof options === 'function') { + options(); + return; + } + if (cb) { + cb(); + } + }, + close: () => {}, + handlers, + }; + sockets.push(socket); + return socket; + }, + }; + + class MessageParserStub { + parse() { + return [ + { + payload: { + gwId: 'device-id', + ip: '1.1.1.1', + version: '3.3', + productKey: 'product-key', + }, + }, + ]; + } + } + + const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', { + dgram: dgramStub, + 'tuyapi/lib/message-parser': { MessageParser: MessageParserStub }, + 'tuyapi/lib/config': { UDP_KEY: 'key' }, + }); + + const clock = sinon.useFakeTimers(); + const promise = localScan({ timeoutSeconds: 1 }); + + // Trigger message on all sockets + sockets.forEach((socket) => { + if (socket.handlers.message) { + socket.handlers.message(Buffer.from('test')); + } + }); + + await clock.tickAsync(1100); + const result = await promise; + clock.restore(); + + expect(result).to.deep.equal({ + devices: { + 'device-id': { + ip: '1.1.1.1', + version: '3.3', + productKey: 'product-key', + }, + }, + portErrors: {}, + }); + }); + + it('should ignore invalid payloads and handle socket errors', async () => { + const sockets = []; + const dgramStub = { + createSocket: () => { + const handlers = {}; + const socket = { + on: (event, cb) => { + handlers[event] = cb; + }, + bind: () => {}, + close: () => { + throw new Error('close error'); + }, + handlers, + }; + sockets.push(socket); + return socket; + }, + }; + + let callIndex = 0; + class MessageParserStub { + parse() { + callIndex += 1; + if (callIndex === 1) { + throw new Error('bad payload'); + } + if (callIndex === 2) { + return [{ payload: 'invalid' }]; + } + return [{ payload: { ip: '1.1.1.1' } }]; + } + } + + const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', { + dgram: dgramStub, + 'tuyapi/lib/message-parser': { MessageParser: MessageParserStub }, + 'tuyapi/lib/config': { UDP_KEY: 'key' }, + }); + + const clock = sinon.useFakeTimers(); + const promise = localScan({ timeoutSeconds: 1 }); + + sockets.forEach((socket) => { + if (socket.handlers.message) { + socket.handlers.message(Buffer.from('test')); + socket.handlers.message(Buffer.from('test')); + socket.handlers.message(Buffer.from('test')); + } + if (socket.handlers.error) { + socket.handlers.error(new Error('boom')); + } + }); + + await clock.tickAsync(1100); + const result = await promise; + clock.restore(); + + expect(result).to.deep.equal({ + devices: {}, + portErrors: { + 6666: 'boom', + 6667: 'boom', + 7000: 'boom', + }, + }); + }); + + it('should handle socket address errors on bind', async () => { + const sockets = []; + const dgramStub = { + createSocket: () => { + const handlers = {}; + const socket = { + on: (event, cb) => { + handlers[event] = cb; + }, + bind: (options, cb) => { + if (handlers.listening) { + handlers.listening(); + } + if (typeof options === 'function') { + options(); + return; + } + if (cb) { + cb(); + } + }, + close: () => {}, + address: () => { + throw new Error('address error'); + }, + handlers, + }; + sockets.push(socket); + return socket; + }, + }; + + class MessageParserStub { + parse() { + return [{ payload: null }]; + } + } + + const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', { + dgram: dgramStub, + 'tuyapi/lib/message-parser': { MessageParser: MessageParserStub }, + 'tuyapi/lib/config': { UDP_KEY: 'key' }, + }); + + const clock = sinon.useFakeTimers(); + const promise = localScan({ timeoutSeconds: 1 }); + await clock.tickAsync(1100); + const result = await promise; + clock.restore(); + + expect(result).to.deep.equal({ + devices: {}, + portErrors: {}, + }); + }); + + it('should log listening address when available', async () => { + const sockets = []; + const dgramStub = { + createSocket: () => { + const handlers = {}; + const socket = { + on: (event, cb) => { + handlers[event] = cb; + }, + bind: () => { + if (handlers.listening) { + handlers.listening(); + } + }, + close: () => {}, + address: () => ({ address: '0.0.0.0', port: 6666 }), + handlers, + }; + sockets.push(socket); + return socket; + }, + }; + + class MessageParserStub { + parse() { + return [{ payload: null }]; + } + } + + const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', { + dgram: dgramStub, + 'tuyapi/lib/message-parser': { MessageParser: MessageParserStub }, + 'tuyapi/lib/config': { UDP_KEY: 'key' }, + }); + + const clock = sinon.useFakeTimers(); + const promise = localScan({ timeoutSeconds: 1 }); + await clock.tickAsync(1100); + await promise; + clock.restore(); + }); +}); + +describe('TuyaHandler.buildLocalScanResponse', () => { + it('should return devices and port errors when discovered devices exist', () => { + const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {}); + const tuyaManager = { + discoveredDevices: [{ external_id: 'tuya:device1', params: [] }], + }; + const response = buildLocalScanResponse(tuyaManager, { + devices: { device1: { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' } }, + portErrors: { 6666: 'boom' }, + }); + + expect(response.port_errors).to.deep.equal({ 6666: 'boom' }); + expect(response.local_devices).to.deep.equal({ device1: { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' } }); + expect(response.devices).to.be.an('array'); + expect(response.devices[0].ip).to.equal('1.1.1.1'); + const ipParam = response.devices[0].params.find((param) => param.name === 'IP_ADDRESS'); + expect(ipParam.value).to.equal('1.1.1.1'); + }); + + it('should merge devices when gladys stateManager exists', () => { + const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {}); + const tuyaManager = { + discoveredDevices: [{ external_id: 'tuya:device1', params: [] }], + gladys: { + stateManager: { + get: sinon.stub().returns({ + external_id: 'tuya:device1', + params: [{ name: 'LOCAL_OVERRIDE', value: true }], + features: [], + }), + }, + }, + }; + const response = buildLocalScanResponse(tuyaManager, { + devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, + portErrors: {}, + }); + + expect(response.devices[0]).to.have.property('updatable'); + expect(response.devices[0].local_override).to.equal(true); + }); + + it('should return only local devices when no discovered devices array', () => { + const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {}); + const tuyaManager = { discoveredDevices: null }; + const response = buildLocalScanResponse(tuyaManager, null); + + expect(response).to.deep.equal({ local_devices: {}, port_errors: {} }); + }); + + it('should append local-only devices when cloud discovered list is empty', () => { + const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', { + './device/tuya.convertDevice': { + convertDevice: sinon.stub().callsFake((device) => ({ + external_id: `tuya:${device.id}`, + params: [{ name: 'IP_ADDRESS', value: device.ip }], + })), + }, + }); + const tuyaManager = { + discoveredDevices: [], + gladys: { + stateManager: { + get: sinon.stub().returns(null), + }, + }, + }; + + const response = buildLocalScanResponse(tuyaManager, { + devices: { device2: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' } }, + portErrors: {}, + }); + + expect(response.devices).to.have.length(1); + expect(response.devices[0].external_id).to.equal('tuya:device2'); + expect(response.local_devices).to.deep.equal({ device2: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' } }); + }); +}); diff --git a/server/test/services/tuya/lib/tuya.manualDisconnect.test.js b/server/test/services/tuya/lib/tuya.manualDisconnect.test.js new file mode 100644 index 0000000000..a0b0a143c8 --- /dev/null +++ b/server/test/services/tuya/lib/tuya.manualDisconnect.test.js @@ -0,0 +1,35 @@ +const sinon = require('sinon'); + +const { assert } = sinon; + +const TuyaHandler = require('../../../../services/tuya/lib/index'); +const { GLADYS_VARIABLES } = require('../../../../services/tuya/lib/utils/tuya.constants'); + +const gladys = { + variable: { + setValue: sinon.fake.resolves(null), + }, +}; +const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0'; + +describe('TuyaHandler.manualDisconnect', () => { + const tuyaHandler = new TuyaHandler(gladys, serviceId); + + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should persist manual disconnect and call disconnect', async () => { + const disconnectStub = sinon.stub().resolves(null); + tuyaHandler.disconnect = disconnectStub; + + await tuyaHandler.manualDisconnect(); + + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, 'true', serviceId); + assert.calledWith(disconnectStub, { manual: true }); + }); +}); diff --git a/server/test/services/tuya/lib/tuya.poll.test.js b/server/test/services/tuya/lib/tuya.poll.test.js index a04fe1883a..a4430967ec 100644 --- a/server/test/services/tuya/lib/tuya.poll.test.js +++ b/server/test/services/tuya/lib/tuya.poll.test.js @@ -20,6 +20,9 @@ const gladys = { variable: { getValue: sinon.stub(), }, + stateManager: { + get: sinon.stub(), + }, event: { emit: fake.returns(null), }, @@ -31,6 +34,9 @@ describe('TuyaHandler.poll', () => { beforeEach(() => { sinon.reset(); + gladys.stateManager.get.resetHistory(); + gladys.stateManager.get.resetBehavior(); + gladys.stateManager.get.returns(null); tuyaHandler.connector = { request: sinon .stub() @@ -103,4 +109,35 @@ describe('TuyaHandler.poll', () => { state: 0, }); }); + + it('should use cached feature state to detect OFF changes', async () => { + tuyaHandler.connector = { + request: sinon.stub().resolves({ + result: [{ code: 'switch_1', value: false }], + }), + }; + + gladys.stateManager.get.withArgs('deviceFeature', 'tuya-device-switch-1').returns({ + last_value: 1, + }); + + await tuyaHandler.poll({ + external_id: 'tuya:device', + features: [ + { + external_id: 'tuya:device:switch_1', + selector: 'tuya-device-switch-1', + category: 'switch', + type: 'binary', + last_value: 0, + }, + ], + }); + + assert.callCount(gladys.event.emit, 1); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'tuya:device:switch_1', + state: 0, + }); + }); }); diff --git a/server/test/services/tuya/lib/tuya.saveConfiguration.test.js b/server/test/services/tuya/lib/tuya.saveConfiguration.test.js index a07c47b576..0ec30ff22d 100644 --- a/server/test/services/tuya/lib/tuya.saveConfiguration.test.js +++ b/server/test/services/tuya/lib/tuya.saveConfiguration.test.js @@ -30,16 +30,20 @@ describe('TuyaHandler.saveConfiguration', () => { accessKey: 'accessKey', secretKey: 'secretKey', appAccountId: 'appAccountUID', + appUsername: 'user@example.com', }; const config = await tuyaHandler.saveConfiguration(configuration); expect(config).to.deep.eq(configuration); - assert.callCount(gladys.variable.setValue, 4); + assert.callCount(gladys.variable.setValue, 7); assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.ENDPOINT, 'endpoint', serviceId); assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.ACCESS_KEY, 'accessKey', serviceId); assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.SECRET_KEY, 'secretKey', serviceId); assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, 'appAccountUID', serviceId); + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.APP_USERNAME, 'user@example.com', serviceId); + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', serviceId); + assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, '', serviceId); }); }); diff --git a/server/test/services/tuya/lib/tuya.setValue.test.js b/server/test/services/tuya/lib/tuya.setValue.test.js index 4cec8115fb..0a302d5283 100644 --- a/server/test/services/tuya/lib/tuya.setValue.test.js +++ b/server/test/services/tuya/lib/tuya.setValue.test.js @@ -1,10 +1,14 @@ +/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ const sinon = require('sinon'); +const proxyquire = require('proxyquire') + .noCallThru() + .noPreserveCache(); const { assert, fake } = sinon; const { expect } = require('chai'); const TuyaHandler = require('../../../../services/tuya/lib/index'); -const { API } = require('../../../../services/tuya/lib/utils/tuya.constants'); +const { API, DEVICE_PARAM_NAME } = require('../../../../services/tuya/lib/utils/tuya.constants'); const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants'); const { BadParameters } = require('../../../../utils/coreErrors'); @@ -88,4 +92,267 @@ describe('TuyaHandler.setValue', () => { body: { commands: [{ code: 'switch_0', value: true }] }, }); }); + + it('should call local tuyapi when local params are set', async () => { + const connect = sinon.stub().resolves(); + const set = sinon.stub().resolves(); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.set = set; + this.disconnect = disconnect; + } + const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', { + tuyapi: TuyAPIStub, + }); + + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:switch_1', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub() }, + gladys: {}, + }; + + await setValue.call(ctx, device, deviceFeature, 1); + + expect(connect.calledOnce).to.equal(true); + expect(set.calledOnce).to.equal(true); + expect(disconnect.calledOnce).to.equal(true); + expect(ctx.connector.request.called).to.equal(false); + }); + + it('should call local tuyapi with switch code', async () => { + const connect = sinon.stub().resolves(); + const set = sinon.stub().resolves(); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.set = set; + this.disconnect = disconnect; + } + const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', { + tuyapi: TuyAPIStub, + }); + + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:switch', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub() }, + gladys: {}, + }; + + await setValue.call(ctx, device, deviceFeature, 1); + + expect(connect.calledOnce).to.equal(true); + expect(set.calledOnce).to.equal(true); + expect(disconnect.calledOnce).to.equal(true); + expect(ctx.connector.request.called).to.equal(false); + }); + + it('should fallback to cloud when local override is false', async () => { + const connect = sinon.stub().resolves(); + const set = sinon.stub().resolves(); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.set = set; + this.disconnect = disconnect; + } + const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', { + tuyapi: TuyAPIStub, + }); + + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: 'false' }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:switch_1', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub().resolves({}) }, + gladys: {}, + }; + + await setValue.call(ctx, device, deviceFeature, 1); + + expect(connect.called).to.equal(false); + expect(ctx.connector.request.calledOnce).to.equal(true); + }); + + it('should fallback to cloud when dps is not mapped', async () => { + const connect = sinon.stub().resolves(); + const set = sinon.stub().resolves(); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.set = set; + this.disconnect = disconnect; + } + const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', { + tuyapi: TuyAPIStub, + }); + + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:countdown', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub().resolves({}) }, + gladys: {}, + }; + + await setValue.call(ctx, device, deviceFeature, 1); + + expect(connect.called).to.equal(false); + expect(ctx.connector.request.calledOnce).to.equal(true); + }); + + it('should fallback to cloud when local call fails', async () => { + const connect = sinon.stub().rejects(new Error('local error')); + const set = sinon.stub().resolves(); + const disconnect = sinon.stub().resolves(); + function TuyAPIStub() { + this.connect = connect; + this.set = set; + this.disconnect = disconnect; + } + const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', { + tuyapi: TuyAPIStub, + }); + + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:switch_1', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub().resolves({}) }, + gladys: {}, + }; + + await setValue.call(ctx, device, deviceFeature, 1); + + expect(ctx.connector.request.calledOnce).to.equal(true); + }); + + it('should fallback to cloud when command is empty', async () => { + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub().resolves({}) }, + gladys: {}, + }; + + await tuyaHandler.setValue.call(ctx, device, deviceFeature, 1); + + expect(ctx.connector.request.calledOnce).to.equal(true); + }); + + it('should log disconnect failures and still return on local success', async () => { + const connect = sinon.stub().resolves(); + const set = sinon.stub().resolves(); + const disconnect = sinon.stub().rejects(new Error('disconnect error')); + function TuyAPIStub() { + this.connect = connect; + this.set = set; + this.disconnect = disconnect; + } + const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', { + tuyapi: TuyAPIStub, + }); + + const device = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' }, + { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const deviceFeature = { + external_id: 'tuya:device:switch_1', + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + }; + + const ctx = { + connector: { request: sinon.stub() }, + gladys: {}, + }; + + await setValue.call(ctx, device, deviceFeature, 1); + + expect(connect.calledOnce).to.equal(true); + expect(set.calledOnce).to.equal(true); + expect(disconnect.calledOnce).to.equal(true); + expect(ctx.connector.request.called).to.equal(false); + }); }); diff --git a/server/test/services/tuya/lib/utils/tuya.deviceParams.test.js b/server/test/services/tuya/lib/utils/tuya.deviceParams.test.js new file mode 100644 index 0000000000..019affa968 --- /dev/null +++ b/server/test/services/tuya/lib/utils/tuya.deviceParams.test.js @@ -0,0 +1,182 @@ +/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ +const { expect } = require('chai'); + +const { + applyExistingLocalOverride, + applyExistingLocalParams, + getParamValue, + normalizeExistingDevice, + updateDiscoveredDeviceWithLocalInfo, + upsertParam, +} = require('../../../../../services/tuya/lib/utils/tuya.deviceParams'); +const { DEVICE_PARAM_NAME } = require('../../../../../services/tuya/lib/utils/tuya.constants'); + +describe('Tuya device params utils', () => { + it('should upsert params', () => { + const params = [{ name: 'test', value: 1 }]; + upsertParam(params, 'test', 2); + expect(params[0].value).to.equal(2); + upsertParam(params, 'new', 'value'); + expect(params.find((param) => param.name === 'new').value).to.equal('value'); + }); + + it('should ignore upsert when value is null or undefined', () => { + const params = [{ name: 'test', value: 1 }]; + upsertParam(params, 'test', null); + upsertParam(params, 'other', undefined); + expect(params).to.deep.equal([{ name: 'test', value: 1 }]); + }); + + it('should normalize existing device local override', () => { + const device = { + params: [ + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }, + { name: 'OTHER', value: 'x' }, + ], + }; + const normalized = normalizeExistingDevice(device); + const override = normalized.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE); + expect(override.value).to.equal(true); + const other = normalized.params.find((param) => param.name === 'OTHER'); + expect(other.value).to.equal('x'); + }); + + it('should get param value', () => { + const params = [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '1.1.1.1' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + ]; + expect(getParamValue(params, DEVICE_PARAM_NAME.IP_ADDRESS)).to.equal('1.1.1.1'); + expect(getParamValue(params, 'MISSING')).to.equal(undefined); + }); + + it('should not normalize local override when value is null', () => { + const device = { + params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: null }], + }; + const normalized = normalizeExistingDevice(device); + const override = normalized.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE); + expect(override.value).to.equal(null); + }); + + it('should return device when normalizeExistingDevice has no params', () => { + const device = { id: 'device' }; + const normalized = normalizeExistingDevice(device); + expect(normalized).to.equal(device); + }); + + it('should update discovered device with local info', () => { + const device = { ip: 'old', params: [] }; + const localInfo = { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' }; + const updated = updateDiscoveredDeviceWithLocalInfo(device, localInfo); + const ipParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.IP_ADDRESS); + const protocolParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.PROTOCOL_VERSION); + const productKeyParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.PRODUCT_KEY); + + expect(updated.ip).to.equal('1.1.1.1'); + expect(updated.protocol_version).to.equal('3.3'); + expect(updated.product_key).to.equal('pkey'); + expect(ipParam.value).to.equal('1.1.1.1'); + expect(protocolParam.value).to.equal('3.3'); + expect(productKeyParam.value).to.equal('pkey'); + }); + + it('should keep protocol and product key when local info is partial', () => { + const device = { ip: 'old', protocol_version: '3.3', product_key: 'pkey', params: [] }; + const localInfo = { ip: '2.2.2.2' }; + const updated = updateDiscoveredDeviceWithLocalInfo(device, localInfo); + expect(updated.ip).to.equal('2.2.2.2'); + expect(updated.protocol_version).to.equal('3.3'); + expect(updated.product_key).to.equal('pkey'); + }); + + it('should return device when no local info is provided', () => { + const device = { ip: 'old' }; + const updated = updateDiscoveredDeviceWithLocalInfo(device, null); + expect(updated).to.equal(device); + }); + + it('should return device when updateDiscoveredDeviceWithLocalInfo has no device', () => { + const updated = updateDiscoveredDeviceWithLocalInfo(null, { ip: '1.1.1.1' }); + expect(updated).to.equal(null); + }); + + it('should apply existing local params', () => { + const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] }; + const existingDevice = { + params: [ + { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '2.2.2.2' }, + { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' }, + { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }, + ], + }; + const updated = applyExistingLocalParams(device, existingDevice); + expect(updated.ip).to.equal('2.2.2.2'); + expect(updated.protocol_version).to.equal('3.3'); + expect(updated.local_override).to.equal(true); + }); + + it('should keep device values when existing params are not an array', () => { + const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] }; + const existingDevice = { params: null }; + const updated = applyExistingLocalParams(device, existingDevice); + expect(updated.ip).to.equal('old'); + expect(updated.protocol_version).to.equal('3.1'); + expect(updated.local_override).to.equal(false); + }); + + it('should normalize local override when applying existing params', () => { + const device = { ip: 'old', protocol_version: '3.1', local_override: true, params: [] }; + const existingDevice = { + params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: 'false' }], + }; + const updated = applyExistingLocalParams(device, existingDevice); + expect(updated.local_override).to.equal(false); + }); + + it('should keep device values when existing params are missing', () => { + const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] }; + const existingDevice = { params: [] }; + const updated = applyExistingLocalParams(device, existingDevice); + expect(updated.ip).to.equal('old'); + expect(updated.protocol_version).to.equal('3.1'); + expect(updated.local_override).to.equal(false); + }); + + it('should return device when applyExistingLocalParams has no existing device', () => { + const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] }; + const updated = applyExistingLocalParams(device, null); + expect(updated).to.equal(device); + }); + + it('should apply existing local override when present', () => { + const device = { params: [] }; + const existingDevice = { params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }] }; + const updated = applyExistingLocalOverride(device, existingDevice); + const overrideParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE); + expect(updated.local_override).to.equal(true); + expect(overrideParam.value).to.equal(true); + }); + + it('should normalize existing local override when applying override', () => { + const device = { params: [] }; + const existingDevice = { params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: 'false' }] }; + const updated = applyExistingLocalOverride(device, existingDevice); + const overrideParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE); + expect(updated.local_override).to.equal(false); + expect(overrideParam.value).to.equal(false); + }); + + it('should return device when no local override is present', () => { + const device = { params: [] }; + const existingDevice = { params: [{ name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '1.1.1.1' }] }; + const updated = applyExistingLocalOverride(device, existingDevice); + expect(updated).to.equal(device); + }); + + it('should return device when applyExistingLocalOverride has no existing params', () => { + const device = { params: [] }; + const updated = applyExistingLocalOverride(device, null); + expect(updated).to.equal(device); + }); +}); diff --git a/server/test/services/tuya/tuya.mock.test.js b/server/test/services/tuya/tuya.mock.test.js index e8e0c4071f..fc1515318c 100644 --- a/server/test/services/tuya/tuya.mock.test.js +++ b/server/test/services/tuya/tuya.mock.test.js @@ -6,6 +6,7 @@ const client = { const TuyaContext = function TuyaContext() { this.client = client; + this.request = sinon.stub().resolves({ result: { list: [] }, success: true }); }; module.exports = { diff --git a/server/utils/constants.js b/server/utils/constants.js index 162535eded..24551cca85 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -1370,6 +1370,7 @@ const WEBSOCKET_MESSAGE_TYPES = { TUYA: { STATUS: 'tuya.status', DISCOVER: 'tuya.discover', + ERROR: 'tuya.error', }, NETATMO: { STATUS: 'netatmo.status',