diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index b9963df0df..08e763814d 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -1201,6 +1201,7 @@ "alreadyCreatedButton": "Bereits erstellt", "deleteButton": "Löschen", "unmanagedModelButton": "Modell nicht verwaltet oder verfügbar", + "partiallyManagedModelButton": "Modell teilweise unterstützt", "status": { "notConnected": "Gladys konnte keine Verbindung zum Tuya-Cloud-Account herstellen. Bitte gehe zur ", "setupPageLink": "Tuya-Einrichtungsseite.", @@ -1212,23 +1213,67 @@ "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", + "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": "Lokale Abfrage 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": "Lokale Abfrage OK.", + "localPollError": "Lokale Abfrage fehlgeschlagen:", + "productIdLabel": "Produkt-ID", + "productKeyLabel": "Produktschlüssel", + "createGithubIssue": "Dieses Gerät vorschlagen", + "createGithubIssuePartial": "Neue Funktionen vorschlagen", + "githubIssueLocalPrepInfo": "Um alle lokalen Informationen im Issue zu erfassen, kannst du zuerst auf der Erkennungsseite einen Lokalen Auto-Scan starten, damit alle Geräte mit ihren lokalen Daten angereichert werden.
Wechsle danach das Gerät bei Bedarf in den lokalen Modus, trage IP und Protokoll ein und führe anschließend Lokales DPS abfragen erfolgreich aus, bevor du auf die Vorschlags-Schaltfläche klickst.", + "githubIssueInfo": "Ein Bericht wird mit den Gerätedetails gesendet. Sensible Daten (Lokaler Schlüssel und IP-Adresse) werden maskiert.", + "githubIssuePayloadCopied": "Issue-Details wurden in die Zwischenablage kopiert. Bitte in den GitHub-Issue-Text einfügen.", + "githubIssuePayloadInfo": "Die Issue-Details sind zu lang für die URL. Bitte den Inhalt unten kopieren und in den GitHub-Issue-Text einfügen.", + "githubIssuePayloadCopyButton": "Inhalt kopieren", + "githubIssuePayloadOpenEmptyButton": "Leeres Issue erstellen", + "githubIssueExistsInfo": "Für dieses Gerät existiert bereits ein Issue. Ein Entwickler kümmert sich demnächst darum. Bei Fragen gerne im Forum community.gladysassistant.com.

Die passenden GitHub-Issues findest du hier: Issue-Liste.", + "githubIssueCreateAnywayInfo": "Bei Bedarf kannst du trotzdem ein Folge-Issue auf Basis des neuesten (#{{issueNumber}}) erstellen. Prüfe vorher, ob bereits ein anderes ähnliches, noch offenes Issue existiert. Falls ja und es dir gehört, schließe es oder aktualisiere es bei Bedarf, bevor du ein neues erstellst.", + "githubIssueCreateAnywayButton": "Folge-Issue erstellen", + "partialFeaturesCount": "({{count}} nicht unterstützt)", + "partialFeaturesCountDiscover": "({{count}} noch nicht unterstützt)" }, "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", @@ -1238,17 +1283,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.", @@ -3362,7 +3420,6 @@ "category": { "button": { "click": { - "unknown": "{{value}} (unbekannter Wert)", "1": "Einfacher Klick", "2": "Doppelter Klick", "3": "Langer Klick (drücken)", @@ -3446,7 +3503,8 @@ "81": "Halten Minus", "82": "Loslassen Plus", "83": "Loslassen Mitte", - "84": "Loslassen Minus" + "84": "Loslassen Minus", + "unknown": "{{value}} (unbekannter Wert)" } }, "heater": { @@ -3825,6 +3883,7 @@ "binary": "Schalter", "power": "Leistung", "energy": "Energie", + "export-index": "Einspeiseindex", "index": "Index", "index-today": "Index heute", "index-yesterday": "Index gestern", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index aaf78b67d9..8a624a4cfa 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -1096,6 +1096,7 @@ "alreadyCreatedButton": "Already created", "deleteButton": "Delete", "unmanagedModelButton": "Model not managed or available", + "partiallyManagedModelButton": "Model partially managed", "status": { "notConnected": "Gladys failed to connect to Tuya cloud account, please go to ", "setupPageLink": "Tuya configuration page.", @@ -1107,23 +1108,67 @@ "updates": "Check updates", "editButton": "Edit", "noDeviceFound": "No Tuya device found.", - "featuresLabel": "Features" + "featuresLabel": "Features", + "idLabel": "Device ID", + "localKeyLabel": "Local Key", + "protocolVersionLabel": "Protocol Version", + "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", + "createGithubIssue": "Suggest this device", + "createGithubIssuePartial": "Suggest new functions", + "githubIssueLocalPrepInfo": "To include all local information in the issue, you can first run Local auto scan from the discovery page to enrich all devices with their local data.
Then switch the device to local mode if needed, fill in the IP and protocol, and run a successful Poll local DPS before clicking the suggestion button.", + "githubIssueInfo": "A report will be sent with the device details. Sensitive data (local key and IP) is masked.", + "githubIssuePayloadCopied": "Issue details copied to clipboard. Paste them into the GitHub issue body.", + "githubIssuePayloadInfo": "The issue details are too long for the URL. Copy the content below and paste it into the GitHub issue body.", + "githubIssuePayloadCopyButton": "Copy content", + "githubIssuePayloadOpenEmptyButton": "Create empty issue", + "githubIssueExistsInfo": "An issue already exists for this device. A developer will take care of it soon. Feel free to ask on the forum community.gladysassistant.com.

You can view related issues on GitHub here: issues list.", + "githubIssueCreateAnywayInfo": "If needed, you can still create a follow-up issue based on the latest one (#{{issueNumber}}). Before creating a new one, check whether another similar open issue already exists. If so, and you own it, close it or update it if needed before creating a new one.", + "githubIssueCreateAnywayButton": "Create follow-up issue", + "partialFeaturesCount": "({{count}} not implemented)", + "partialFeaturesCountDiscover": "({{count}} not yet supported)" }, "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 Scan Refreshes 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 scan Runs 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", @@ -1133,17 +1178,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.", @@ -3362,7 +3420,6 @@ "category": { "button": { "click": { - "unknown": "{{value}} (unknown value)", "1": "Simple click", "2": "Double click", "3": "Long click press", @@ -3446,7 +3503,8 @@ "81": "Hold Minus", "82": "Release Plus (Aqara W100)", "83": "Release Center (Aqara W100)", - "84": "Release Minus (Aqara W100)" + "84": "Release Minus (Aqara W100)", + "unknown": "{{value}} (unknown value)" } }, "heater": { @@ -3825,6 +3883,7 @@ "binary": "Switch", "power": "Power", "energy": "Energy", + "export-index": "Export index", "index": "Index", "index-today": "Index today", "index-yesterday": "Index yesterday", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 83f8999af4..9130461101 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1331,6 +1331,7 @@ "alreadyCreatedButton": "Déjà créé", "deleteButton": "Supprimer", "unmanagedModelButton": "Modèle non pris en charge ou non disponible", + "partiallyManagedModelButton": "Modèle partiellement pris en charge", "status": { "notConnected": "Gladys n'a pas réussi à se connecter au compte cloud Tuya, plus d'informations sur la ", "setupPageLink": "page de configuration Tuya.", @@ -1342,23 +1343,67 @@ "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", + "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 une analyse complète (plus longue).", + "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", + "createGithubIssue": "Proposer ce périphérique", + "createGithubIssuePartial": "Proposer nouvelles fonctions", + "githubIssueLocalPrepInfo": "Pour inclure toutes les informations locales dans l'issue, vous pouvez d'abord lancer un Scan local auto depuis la page de découverte pour enrichir tous les appareils avec leurs données locales.
Ensuite, passez en mode local sur l'appareil si nécessaire, renseignez l'IP et le protocole, puis lancez une Lecture locale des DP avec succès avant de cliquer sur le bouton de proposition.", + "githubIssueInfo": "Un rapport sera envoyé avec les informations du périphérique. Les données sensibles (clé locale et IP) sont masquées.", + "githubIssuePayloadCopied": "Les détails ont été copiés dans le presse-papiers. Collez-les dans le corps de l'issue GitHub.", + "githubIssuePayloadInfo": "Les détails sont trop longs pour l'URL. Copiez le contenu ci-dessous et collez-le dans le corps de l'issue GitHub.", + "githubIssuePayloadCopyButton": "Copier le contenu", + "githubIssuePayloadOpenEmptyButton": "Créer l'issue vide", + "githubIssueExistsInfo": "Une issue existe déjà pour ce périphérique. Un développeur s'en occupera prochainement. N'hésitez pas à demander sur le forum community.gladysassistant.com.

Vous pouvez consulter les issues correspondantes sur GitHub ici : liste des issues.", + "githubIssueCreateAnywayInfo": "Si besoin, vous pouvez tout de même créer une issue de suivi basée sur la plus récente (#{{issueNumber}}). Avant d'en recréer une nouvelle, vérifiez qu'une autre issue similaire encore ouverte n'existe pas déjà. Si c'est le cas et que vous en êtes propriétaire, fermez-la ou mettez-la à jour si nécessaire avant de créer la nouvelle.", + "githubIssueCreateAnywayButton": "Créer une issue de suivi", + "partialFeaturesCount": "({{count}} non implémentée(s))", + "partialFeaturesCountDiscover": "({{count}} non implémentable(s))" }, "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 Cloud Rafraî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 auto Lance 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", @@ -1368,17 +1413,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.", @@ -3362,7 +3420,6 @@ "category": { "button": { "click": { - "unknown": "{{value}} (valeur inconnue)", "1": "Clic simple", "2": "Clic double", "3": "Pression clic long", @@ -3446,7 +3503,8 @@ "81": "Maintien Moins", "82": "Relâchement Plus", "83": "Relâchement Centre", - "84": "Relâchement Moins" + "84": "Relâchement Moins", + "unknown": "{{value}} (valeur inconnue)" } }, "heater": { @@ -3825,6 +3883,7 @@ "binary": "Relais", "power": "Puissance", "energy": "Energie", + "export-index": "Index export", "index": "Index", "index-today": "Index aujourd'hui", "index-yesterday": "Index hier", diff --git a/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx b/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx index 7e202253a0..d0f446c0b9 100644 --- a/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx +++ b/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx @@ -5,17 +5,149 @@ import { Link } from 'preact-router'; import get from 'get-value'; import DeviceFeatures from '../../../../components/device/view/DeviceFeatures'; import { connect } from 'unistore/preact'; +import { + normalizeBoolean, + buildParamsMap, + getLocalOverrideValue, + getTuyaDeviceId, + getLocalPollDpsFromParams, + getUnknownDpsKeys, + getUnknownSpecificationCodes, + resolveOnlineStatus +} from './commons/deviceHelpers'; +import TuyaLocalPollSection from './TuyaLocalPollSection'; +import TuyaGithubIssueSection from './discover-page/TuyaGithubIssueSection'; + +const LOCAL_POLL_FREQUENCY = 10 * 1000; +const CLOUD_POLL_FREQUENCY = 30 * 1000; + +const getDeviceDisplayData = device => { + const params = buildParamsMap(device); + return { + deviceId: params.DEVICE_ID || getTuyaDeviceId(device), + localKey: params.LOCAL_KEY || device.local_key || '', + productId: params.PRODUCT_ID || device.product_id || '', + productKey: params.PRODUCT_KEY || device.product_key || '', + localOverride: normalizeBoolean(getLocalOverrideValue(device)), + persistedLocalPollDps: getLocalPollDpsFromParams(device) + }; +}; + +const buildComparableDevice = device => { + if (!device) { + return null; + } + const params = buildParamsMap(device); + 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(getLocalOverrideValue(device)) + }; +}; + +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); + return { + ip: params.IP_ADDRESS || device.ip || '', + protocol: params.PROTOCOL_VERSION || device.protocol_version || '', + localOverride: normalizeBoolean(getLocalOverrideValue(device)) + }; +}; + +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; + +const getLocalValidationState = (device, baselineDevice, localPollValidation) => { + const currentLocalConfig = getLocalConfig(device); + const baselineLocalConfig = getLocalConfig(baselineDevice); + const localConfigChanged = hasLocalConfigChanged(currentLocalConfig, baselineLocalConfig); + const requiresLocalPollValidation = currentLocalConfig.localOverride === true && localConfigChanged; + const localPollValidated = isLocalPollValidated(localPollValidation, currentLocalConfig); + return { + hasLocalChanges: hasDeviceChanged(device, baselineDevice), + requiresLocalPollValidation, + localPollValidated, + canSave: !requiresLocalPollValidation || localPollValidated + }; +}; + +const getUnknownKeys = (device, effectiveLocalPollDps) => { + if (effectiveLocalPollDps) { + return getUnknownDpsKeys(effectiveLocalPollDps, device.features, device); + } + return getUnknownSpecificationCodes(device.specifications, device.features, device); +}; class TuyaDeviceBox extends Component { componentWillMount() { this.setState({ - device: this.props.device + device: this.props.device, + baselineDevice: this.props.device, + localPollValidation: null, + localPollDps: 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; + let mergedNextDevice = + currentDevice && currentDevice.specifications && !nextDevice.specifications + ? { ...nextDevice, specifications: currentDevice.specifications } + : nextDevice; + if (currentDevice && currentDevice.tuya_report && !mergedNextDevice.tuya_report) { + mergedNextDevice = { + ...mergedNextDevice, + tuya_report: currentDevice.tuya_report + }; + } + if (isNewDevice) { + this.setState({ + device: mergedNextDevice, + baselineDevice: mergedNextDevice, + localPollValidation: null, + localPollDps: null + }); + return; + } this.setState({ - device: nextProps.device + device: mergedNextDevice, + baselineDevice: shouldRefreshBaseline ? mergedNextDevice : baselineDevice }); } @@ -37,19 +169,44 @@ class TuyaDeviceBox extends Component { }); }; + handleLocalPollChange = patch => { + this.setState(patch); + }; + saveDevice = async () => { this.setState({ loading: true, errorMessage: null }); try { - const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device); + const localConfig = getLocalConfig(this.state.device); + const baselineFeatures = this.state.baselineDevice && this.state.baselineDevice.features; + const currentFeatures = this.state.device && this.state.device.features; + const shouldFallbackToBaselineFeatures = + !!this.state.device.created_at && + localConfig.localOverride === true && + Array.isArray(baselineFeatures) && + baselineFeatures.length > 0 && + (!Array.isArray(currentFeatures) || currentFeatures.length === 0); + + const payload = { + ...this.state.device, + poll_frequency: localConfig.localOverride ? LOCAL_POLL_FREQUENCY : CLOUD_POLL_FREQUENCY + }; + if (shouldFallbackToBaselineFeatures) { + payload.features = baselineFeatures; + } + const savedDevice = await this.props.httpClient.post(`/api/v1/device`, payload); 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 +258,51 @@ class TuyaDeviceBox extends Component { alreadyCreatedButton, housesWithRooms }, - { device, loading, errorMessage, tooMuchStatesError, statesNumber } + { + device, + loading, + errorMessage, + tooMuchStatesError, + statesNumber, + localPollStatus, + localPollError, + localPollProtocol, + localPollValidation, + localPollDps + } ) { const validModel = device.features && device.features.length > 0; - const online = device.online; + const online = resolveOnlineStatus(device); + const { deviceId, localKey, productId, productKey, localOverride, persistedLocalPollDps } = getDeviceDisplayData( + device + ); + const { hasLocalChanges, requiresLocalPollValidation, localPollValidated, canSave } = getLocalValidationState( + device, + this.state.baselineDevice, + localPollValidation + ); + const isDiscoverPage = !deleteButton; + const showUpdateButton = + validModel && isDiscoverPage && (updateButton || (alreadyCreatedButton && hasLocalChanges)); + const showAlreadyCreatedButton = validModel && alreadyCreatedButton && !hasLocalChanges; + const effectiveLocalPollDps = localOverride ? localPollDps || persistedLocalPollDps : null; + const unknownKeys = getUnknownKeys(device, effectiveLocalPollDps); + const hasPartialSupport = validModel && unknownKeys.length > 0; + const partialCountLabelId = + isDiscoverPage && !device.created_at + ? 'integration.tuya.device.partialFeaturesCountDiscover' + : 'integration.tuya.device.partialFeaturesCount'; + + const renderGithubIssuePrepAlert = titleId => ( +
+
+ +
+
+ +
+
+ ); return (
@@ -147,7 +345,7 @@ class TuyaDeviceBox extends Component { onInput={this.updateName} class="form-control" placeholder={} - disabled={!editable || !validModel} + disabled={!editable} />
@@ -165,6 +363,19 @@ class TuyaDeviceBox extends Component { /> +
+ + +
+
+
+ + +
+ + {productKey && ( +
+ + +
+ )} + +
+ + +
+ + + {validModel && (
)} + {hasPartialSupport && renderGithubIssuePrepAlert('integration.tuya.partiallyManagedModelButton')} +
- {validModel && alreadyCreatedButton && ( - + {requiresLocalPollValidation && !localPollValidated && ( +
+ +
)} +
+
+ {showAlreadyCreatedButton && ( + + )} - {validModel && updateButton && ( - - )} + {showUpdateButton && ( + + )} - {validModel && saveButton && ( - - )} + {validModel && saveButton && ( + + )} - {validModel && deleteButton && ( - - )} + {validModel && deleteButton && ( + + )} +
+
{!validModel && ( - +
+ {renderGithubIssuePrepAlert('integration.tuya.unmanagedModelButton')} + {isDiscoverPage && ( + + )} +
)} {validModel && editButton && ( @@ -239,6 +527,16 @@ class TuyaDeviceBox extends Component { )}
+ {hasPartialSupport && isDiscoverPage && ( + + )} diff --git a/front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx b/front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx new file mode 100644 index 0000000000..3707065655 --- /dev/null +++ b/front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx @@ -0,0 +1,224 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import { RequestStatus } from '../../../../utils/consts'; +import { buildParamsMap, normalizeBoolean, getLocalOverrideValue, getTuyaDeviceId } from './commons/deviceHelpers'; +import { pollLocalDevice } from './commons/localPoll'; + +const PROTOCOL_OPTIONS = ['3.1', '3.3', '3.4', '3.5']; + +class TuyaLocalPollSection extends Component { + toggleIpMode = () => { + const { device, onLocalPollChange } = this.props; + if (!device || typeof onLocalPollChange !== 'function') { + return; + } + const params = Array.isArray(device.params) ? [...device.params] : []; + const currentOverride = normalizeBoolean(getLocalOverrideValue(device)); + 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 }); + } + onLocalPollChange({ + device: { + ...device, + params, + local_override: nextOverride + }, + localPollValidation: null, + localPollStatus: null, + localPollError: null, + localPollDps: null + }); + }; + + updateProtocol = e => { + const { device, onLocalPollChange } = this.props; + if (!device || typeof onLocalPollChange !== 'function') { + return; + } + const protocolVersion = e.target.value; + const params = Array.isArray(device.params) ? [...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 }); + } + onLocalPollChange({ + device: { + ...device, + params + }, + localPollValidation: null, + localPollStatus: null, + localPollError: null, + localPollDps: null + }); + }; + + updateIpAddress = e => { + const { device, onLocalPollChange } = this.props; + if (!device || typeof onLocalPollChange !== 'function') { + return; + } + const ipAddress = e.target.value; + const params = Array.isArray(device.params) ? [...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 }); + } + onLocalPollChange({ + device: { + ...device, + params + }, + localPollValidation: null, + localPollStatus: null, + localPollError: null, + localPollDps: null + }); + }; + + pollLocal = async () => { + const { httpClient, device, onLocalPollChange } = this.props; + if (!httpClient || !device || typeof onLocalPollChange !== 'function') { + return; + } + onLocalPollChange({ + localPollStatus: RequestStatus.Getting, + localPollError: null, + localPollProtocol: null, + localPollDps: null + }); + try { + const pollResult = await pollLocalDevice({ + httpClient, + device, + onProtocolAttempt: protocolVersion => { + onLocalPollChange({ + localPollProtocol: protocolVersion + }); + } + }); + onLocalPollChange({ + device: pollResult.device, + localPollStatus: RequestStatus.Success, + localPollError: null, + localPollProtocol: null, + localPollValidation: { + ip: pollResult.ip, + protocol: pollResult.protocol, + localOverride: true + }, + localPollDps: pollResult.dps + }); + } catch (e) { + const message = + (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || 'Unknown error'; + onLocalPollChange({ + localPollStatus: RequestStatus.Error, + localPollError: message, + localPollProtocol: null, + localPollDps: null + }); + } + }; + + render({ device, deviceIndex, localPollStatus, localPollError, localPollProtocol }) { + const params = buildParamsMap(device); + const deviceId = params.DEVICE_ID || getTuyaDeviceId(device); + const localKey = params.LOCAL_KEY || (device && device.local_key) || ''; + const protocolVersion = params.PROTOCOL_VERSION || (device && device.protocol_version) || ''; + const ipAddress = params.IP_ADDRESS || (device && device.ip) || ''; + const cloudIp = params.CLOUD_IP || (device && device.cloud_ip) || ''; + const localOverride = normalizeBoolean(getLocalOverrideValue(device)); + const showCloudIp = localOverride !== true; + const displayIp = showCloudIp ? cloudIp : ipAddress; + const canPollLocal = localOverride === true && !!localKey && !!deviceId; + const pollProtocolLabel = localPollProtocol || protocolVersion || '-'; + + return ( + <> +
+ +
+ +
+ +
+
+ + + +
+ +
+ + +
+ +
+ + {localPollStatus === RequestStatus.Getting && ( + + + )} + {localPollStatus === RequestStatus.Success && ( + + + + )} + {localPollStatus === RequestStatus.Error && ( + + {localPollError} + + )} + + + +
+ + ); + } +} + +export default TuyaLocalPollSection; diff --git a/front/src/routes/integration/all/tuya/commons/deviceHelpers.js b/front/src/routes/integration/all/tuya/commons/deviceHelpers.js new file mode 100644 index 0000000000..3bf5e6e6ed --- /dev/null +++ b/front/src/routes/integration/all/tuya/commons/deviceHelpers.js @@ -0,0 +1,224 @@ +const LOCAL_CODE_ALIASES = { + switch: ['power'], + power: ['switch'] +}; + +export const normalizeBoolean = value => + value === true || value === 1 || value === '1' || value === 'true' || value === 'TRUE'; + +export const resolveOnlineStatus = (device, recentMinutes = 5) => { + const features = Array.isArray(device && device.features) ? device.features : []; + let mostRecentFeatureTimestamp = null; + + for (let i = 0; i < features.length; i += 1) { + const rawDate = features[i] && features[i].last_value_changed; + if (!rawDate) { + continue; + } + let parsedDate = new Date(rawDate); + if (Number.isNaN(parsedDate.getTime()) && typeof rawDate === 'string') { + parsedDate = new Date(rawDate.replace(' ', 'T').replace(' +', '+')); + } + if (Number.isNaN(parsedDate.getTime())) { + continue; + } + const timestamp = parsedDate.getTime(); + if (mostRecentFeatureTimestamp === null || timestamp > mostRecentFeatureTimestamp) { + mostRecentFeatureTimestamp = timestamp; + } + } + + if (mostRecentFeatureTimestamp !== null) { + const isReachableFromRecentFeatures = Date.now() - mostRecentFeatureTimestamp <= recentMinutes * 60 * 1000; + if (isReachableFromRecentFeatures) { + return true; + } + } + + return normalizeBoolean(device && device.online); +}; + +const getIgnoredLocalDps = device => { + const mapping = device && device.tuya_mapping ? device.tuya_mapping : null; + const ignored = mapping && Array.isArray(mapping.ignored_local_dps) ? mapping.ignored_local_dps : []; + return new Set(ignored.map(value => String(value))); +}; + +const getIgnoredCloudCodes = device => { + const mapping = device && device.tuya_mapping ? device.tuya_mapping : null; + const ignored = mapping && Array.isArray(mapping.ignored_cloud_codes) ? mapping.ignored_cloud_codes : []; + return new Set(ignored.map(value => String(value).toLowerCase())); +}; + +const getLocalDpsFromProperties = (code, properties) => { + if (!code || !properties) { + return null; + } + const list = Array.isArray(properties.properties) ? properties.properties : properties; + if (!Array.isArray(list)) { + return null; + } + const normalized = code.toLowerCase(); + const candidates = [normalized, ...(LOCAL_CODE_ALIASES[normalized] || [])]; + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; + const match = list.find(item => item && item.code && item.code.toLowerCase() === candidate); + if (match && match.dp_id !== undefined && match.dp_id !== null) { + return match.dp_id; + } + } + return null; +}; + +const getLocalDpsFromCode = (code, device) => { + if (!code) { + return null; + } + const propertyMatch = getLocalDpsFromProperties(code, device && device.properties); + if (propertyMatch !== null) { + return propertyMatch; + } + const normalized = code.toLowerCase(); + if (normalized === 'switch' || normalized === 'power') { + return 1; + } + const match = normalized.match(/_(\d+)$/); + if (match) { + return parseInt(match[1], 10); + } + return null; +}; + +const getKnownDpsKeys = (features, device) => { + const keys = new Set(); + if (!Array.isArray(features)) { + return keys; + } + features.forEach(feature => { + const parts = (feature.external_id || '').split(':'); + const code = parts.length >= 3 ? parts[2] : null; + const dpsKey = getLocalDpsFromCode(code, device); + if (dpsKey !== null) { + keys.add(String(dpsKey)); + } + }); + return keys; +}; + +export const getUnknownDpsKeys = (localPollDps, features, device) => { + if (!localPollDps || typeof localPollDps !== 'object') { + return []; + } + const knownKeys = getKnownDpsKeys(features, device); + const ignoredDps = getIgnoredLocalDps(device); + return Object.keys(localPollDps).filter(key => !knownKeys.has(key) && !ignoredDps.has(String(key))); +}; + +export const getUnknownSpecificationCodes = (specifications, features, device) => { + if (!specifications || (!Array.isArray(specifications.functions) && !Array.isArray(specifications.status))) { + return []; + } + const knownCodes = new Set(); + const addKnownCode = code => { + if (code !== null && code !== undefined) { + knownCodes.add( + String(code) + .trim() + .toLowerCase() + ); + } + }; + if (Array.isArray(features)) { + features.forEach(feature => { + const parts = (feature.external_id || '').split(':'); + const code = parts.length >= 2 ? parts[parts.length - 1] : null; + addKnownCode(code); + }); + } + const services = Array.isArray(device && device.thing_model && device.thing_model.services) + ? device.thing_model.services + : []; + services.forEach(service => { + const properties = Array.isArray(service && service.properties) ? service.properties : []; + properties.forEach(property => addKnownCode(property && property.code)); + }); + const propertiesPayload = device && device.properties; + const properties = Array.isArray(propertiesPayload) + ? propertiesPayload + : Array.isArray(propertiesPayload && propertiesPayload.properties) + ? propertiesPayload.properties + : []; + properties.forEach(property => addKnownCode(property && property.code)); + const specCodes = new Set(); + ['functions', 'status'].forEach(key => { + const entries = specifications[key]; + if (!Array.isArray(entries)) { + return; + } + entries.forEach(entry => { + if (entry && entry.code) { + specCodes.add(entry.code); + } + }); + }); + const ignoredCodes = getIgnoredCloudCodes(device); + return Array.from(specCodes).filter(code => { + const normalized = String(code) + .trim() + .toLowerCase(); + return !knownCodes.has(normalized) && !ignoredCodes.has(normalized); + }); +}; + +export const buildParamsMap = device => + (Array.isArray(device && device.params) ? device.params : []).reduce((acc, param) => { + acc[param.name] = param.value; + return acc; + }, {}); + +export const getParamValue = (device, name) => { + const params = Array.isArray(device && device.params) ? device.params : []; + const found = params.find(param => param.name === name); + return found ? found.value : undefined; +}; + +export const getLocalOverrideValue = device => { + if (!device) { + return undefined; + } + const localOverrideParam = getParamValue(device, 'LOCAL_OVERRIDE'); + if (localOverrideParam !== undefined && localOverrideParam !== null) { + return localOverrideParam; + } + return device.local_override; +}; + +export const getTuyaDeviceId = device => { + if (!device || !device.external_id) { + return ''; + } + const splitExternalId = String(device.external_id).split(':'); + return splitExternalId[1] || device.external_id; +}; + +export const getLocalPollDpsFromParams = device => { + const raw = getParamValue(device, 'LOCAL_POLL_DPS'); + if (!raw) { + return null; + } + if (typeof raw === 'object') { + return raw; + } + try { + return JSON.parse(raw); + } catch (e) { + return null; + } +}; + +export const getProductIdentifier = device => + device.product_id || + getParamValue(device, 'PRODUCT_ID') || + device.product_key || + getParamValue(device, 'PRODUCT_KEY') || + 'unknown-product'; diff --git a/front/src/routes/integration/all/tuya/commons/localPoll.js b/front/src/routes/integration/all/tuya/commons/localPoll.js new file mode 100644 index 0000000000..5c0fda6ac0 --- /dev/null +++ b/front/src/routes/integration/all/tuya/commons/localPoll.js @@ -0,0 +1,122 @@ +import { normalizeBoolean, getLocalOverrideValue, getTuyaDeviceId } from './deviceHelpers'; + +const TRY_PROTOCOLS = ['3.5', '3.4', '3.3', '3.1']; + +const isValidIpAddress = ip => + typeof ip === 'string' && /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(ip); + +const getParamValue = (params, name) => { + const found = params.find(param => param.name === name); + return found ? found.value : undefined; +}; + +const upsertParam = (params, name, value) => { + if (value === undefined || value === null || value === '') { + return; + } + const index = params.findIndex(param => param.name === name); + if (index >= 0) { + params[index] = { ...params[index], value }; + } else { + params.push({ name, value }); + } +}; + +export const pollLocalDevice = async ({ httpClient, device, onProtocolAttempt }) => { + const currentDevice = device; + const params = Array.isArray(currentDevice && currentDevice.params) ? [...currentDevice.params] : []; + const selectedProtocol = getParamValue(params, 'PROTOCOL_VERSION') || currentDevice.protocol_version; + const deviceId = getTuyaDeviceId(currentDevice) || undefined; + const localKey = getParamValue(params, 'LOCAL_KEY') || currentDevice.local_key; + const localOverride = normalizeBoolean(getLocalOverrideValue(currentDevice)); + let resolvedIp = getParamValue(params, 'IP_ADDRESS') || currentDevice.ip; + let scannedProtocolVersion = null; + let scannedDevice = null; + + if (localOverride === true && localKey && deviceId && !isValidIpAddress(resolvedIp)) { + const localScanResponse = await httpClient.post('/api/v1/service/tuya/local-scan', { + timeoutSeconds: 5 + }); + const localDevices = + localScanResponse && localScanResponse.local_devices && typeof localScanResponse.local_devices === 'object' + ? localScanResponse.local_devices + : {}; + const localInfo = localDevices[deviceId]; + if (!localInfo || !localInfo.ip) { + throw new Error('Local auto scan did not find this device IP'); + } + resolvedIp = localInfo.ip; + scannedProtocolVersion = localInfo.version || null; + upsertParam(params, 'IP_ADDRESS', resolvedIp); + if (scannedProtocolVersion) { + upsertParam(params, 'PROTOCOL_VERSION', scannedProtocolVersion); + } + if (Array.isArray(localScanResponse && localScanResponse.devices)) { + scannedDevice = + localScanResponse.devices.find(localDevice => localDevice.external_id === `tuya:${deviceId}`) || + localScanResponse.devices.find(localDevice => localDevice.external_id === currentDevice.external_id) || + null; + } + } + + const protocolList = selectedProtocol + ? [selectedProtocol] + : scannedProtocolVersion + ? [scannedProtocolVersion, ...TRY_PROTOCOLS.filter(protocol => protocol !== scannedProtocolVersion)] + : TRY_PROTOCOLS; + + 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 { + if (typeof onProtocolAttempt === 'function') { + onProtocolAttempt(protocolVersion); + } + const response = await httpClient.post('/api/v1/service/tuya/local-poll', { + deviceId, + ip: resolvedIp, + localKey, + 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 baseDevice = latestDevice || scannedDevice || currentDevice; + const newParams = Array.isArray(baseDevice && baseDevice.params) ? [...baseDevice.params] : []; + if (resolvedIp) { + upsertParam(newParams, 'IP_ADDRESS', resolvedIp); + } + if (usedProtocol) { + upsertParam(newParams, 'PROTOCOL_VERSION', usedProtocol); + } + + return { + device: { + ...baseDevice, + params: newParams + }, + dps: result ? result.dps : null, + protocol: usedProtocol || '', + ip: resolvedIp || '' + }; +}; diff --git a/front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx index 61cab08e3a..9b11ca22cb 100644 --- a/front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx +++ b/front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx @@ -1,4 +1,4 @@ -import { Text } from 'preact-i18n'; +import { Text, Localizer, MarkupText } from 'preact-i18n'; import { Link } from 'preact-router/match'; import cx from 'classnames'; @@ -9,6 +9,42 @@ import { connect } from 'unistore/preact'; import { Component } from 'preact'; import { RequestStatus } from '../../../../../utils/consts'; +const getDeviceRank = device => { + 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,51 @@ class DiscoverTab extends Component {
- +
+
+ +
+ {udpScanError && ( +
+ +
+ )} + {isLoading && scanTextId && ( +
+
+ +
+ )} + {portErrorPorts.length > 0 && ( +
+ + + + + +
+ )}
-
{errorLoading && (

@@ -87,19 +223,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/TuyaGithubIssueSection.jsx b/front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx new file mode 100644 index 0000000000..b1b42ed864 --- /dev/null +++ b/front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx @@ -0,0 +1,378 @@ +import { Component } from 'preact'; +import cx from 'classnames'; +import { Text, MarkupText } from 'preact-i18n'; +import { + buildIssueTitle, + buildFollowUpIssueTitle, + buildGithubSearchUrl, + checkGithubIssues, + createGithubIssueData, + createEmptyGithubIssueUrl +} from './githubIssue'; +import { getLocalPollDpsFromParams } from '../commons/deviceHelpers'; + +const buildInitialState = () => ({ + githubIssueChecking: false, + githubIssueExists: false, + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: false, + githubIssueLatestIssueNumber: null, + githubIssueTargetTitle: null, + githubIssueSkipDuplicateCheck: false +}); + +class TuyaGithubIssueSection extends Component { + componentWillMount() { + this.setState(buildInitialState()); + } + + componentWillReceiveProps(nextProps) { + const currentDevice = this.props.device; + const nextDevice = nextProps.device; + if (!currentDevice || !nextDevice || currentDevice.external_id !== nextDevice.external_id) { + this.setState(buildInitialState()); + } + } + + startGithubIssueCreation = async ({ skipDuplicateCheck = false, followUp = false } = {}) => { + const { + githubIssueChecking, + githubIssueExists, + githubIssuePayload, + githubIssuePayloadUrl, + githubIssueOpened, + githubIssueLatestIssueNumber + } = this.state; + const { device, localPollStatus, localPollError, localPollValidation, localPollDps } = this.props; + if (!device) { + return; + } + if (githubIssueChecking || githubIssuePayload || githubIssuePayloadUrl || githubIssueOpened) { + return; + } + if (!followUp && githubIssueExists) { + return; + } + const persistedLocalPollDps = getLocalPollDpsFromParams(device); + const effectiveLocalPollDps = localPollDps || persistedLocalPollDps; + const baseIssueTitle = buildIssueTitle(device); + + const popup = window.open('about:blank', '_blank'); + if (popup) { + popup.opener = null; + if (!skipDuplicateCheck) { + popup.document.title = 'GitHub'; + popup.document.body.innerText = 'Searching for existing issues...'; + } + } + let latestIssueNumber = githubIssueLatestIssueNumber; + let shouldOpenIssue = true; + if (!skipDuplicateCheck) { + this.setState({ githubIssueChecking: true }); + try { + const searchResult = await checkGithubIssues(baseIssueTitle); + latestIssueNumber = searchResult.latestIssueNumber; + if (searchResult.exists) { + shouldOpenIssue = false; + this.setState({ + githubIssueExists: true, + githubIssueLatestIssueNumber: searchResult.latestIssueNumber, + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: false, + githubIssueTargetTitle: null, + githubIssueSkipDuplicateCheck: false + }); + } + } catch (error) { + shouldOpenIssue = true; + } finally { + this.setState({ githubIssueChecking: false }); + } + } + + const closePopup = () => { + if (popup && !popup.closed) { + popup.close(); + } + }; + + if (!shouldOpenIssue) { + closePopup(); + this.setState({ + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: false, + githubIssueTargetTitle: null, + githubIssueSkipDuplicateCheck: false + }); + return; + } + + const targetIssueTitle = followUp ? buildFollowUpIssueTitle(baseIssueTitle, latestIssueNumber) : baseIssueTitle; + const issueData = createGithubIssueData( + device, + localPollStatus, + localPollError, + localPollValidation, + effectiveLocalPollDps, + { title: targetIssueTitle } + ); + const issueUrl = issueData.url; + + if (issueData.truncated) { + closePopup(); + this.setState({ + githubIssuePayload: issueData.body, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: issueData.url, + githubIssueOpened: false, + githubIssueTargetTitle: targetIssueTitle, + githubIssueSkipDuplicateCheck: skipDuplicateCheck || followUp, + githubIssueLatestIssueNumber: latestIssueNumber + }); + return; + } + + this.setState({ + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: true, + githubIssueTargetTitle: targetIssueTitle, + githubIssueSkipDuplicateCheck: skipDuplicateCheck || followUp, + githubIssueLatestIssueNumber: latestIssueNumber + }); + + if (popup) { + popup.location = issueUrl; + return; + } + window.open(issueUrl, '_blank'); + }; + + handleCreateGithubIssue = async e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + await this.startGithubIssueCreation({ skipDuplicateCheck: false, followUp: false }); + }; + + handleCreateGithubIssueAnyway = async e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + await this.startGithubIssueCreation({ skipDuplicateCheck: true, followUp: true }); + }; + + copyGithubIssuePayload = async e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + const { githubIssuePayload } = this.state; + if (!githubIssuePayload) { + return; + } + let copied = false; + if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(githubIssuePayload); + copied = true; + } catch (error) { + copied = false; + } + } + if (!copied && this.githubIssueTextarea) { + try { + this.githubIssueTextarea.focus(); + this.githubIssueTextarea.select(); + this.githubIssueTextarea.setSelectionRange(0, this.githubIssueTextarea.value.length); + copied = document.execCommand('copy'); + } catch (error) { + copied = false; + } finally { + this.githubIssueTextarea.blur(); + } + } + this.setState({ githubIssuePayloadCopied: copied }); + }; + + openEmptyGithubIssue = async e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + const { device } = this.props; + const { + githubIssuePayloadUrl, + githubIssueChecking, + githubIssueOpened, + githubIssueTargetTitle, + githubIssueSkipDuplicateCheck + } = this.state; + if (!device || !githubIssuePayloadUrl || githubIssueChecking || githubIssueOpened) { + return; + } + const issueTitle = githubIssueTargetTitle || buildIssueTitle(device); + const popup = window.open('about:blank', '_blank'); + if (popup) { + popup.opener = null; + } + if (githubIssueSkipDuplicateCheck) { + if (popup && !popup.closed) { + popup.location.href = createEmptyGithubIssueUrl(issueTitle); + } else { + window.open(createEmptyGithubIssueUrl(issueTitle), '_blank'); + } + this.setState({ githubIssueOpened: true }); + return; + } + this.setState({ githubIssueChecking: true }); + let shouldOpenIssue = true; + try { + const searchResult = await checkGithubIssues(issueTitle); + if (searchResult.exists) { + shouldOpenIssue = false; + if (popup && !popup.closed) { + popup.close(); + } + this.setState({ + githubIssueExists: true, + githubIssueLatestIssueNumber: searchResult.latestIssueNumber + }); + } + } catch (error) { + shouldOpenIssue = true; + } finally { + this.setState({ githubIssueChecking: false }); + } + if (!shouldOpenIssue) { + return; + } + if (popup && !popup.closed) { + popup.location.href = createEmptyGithubIssueUrl(issueTitle); + } else { + window.open(createEmptyGithubIssueUrl(issueTitle), '_blank'); + } + this.setState({ githubIssueOpened: true }); + }; + + render({ actionLabelId, device, withMargin = true, showInfoWhenNoIssue = true }) { + const { + githubIssueChecking, + githubIssueExists, + githubIssuePayload, + githubIssuePayloadCopied, + githubIssuePayloadUrl, + githubIssueOpened, + githubIssueLatestIssueNumber + } = this.state; + if (!device) { + return null; + } + const disableGithubIssueButton = + githubIssueChecking || githubIssueExists || githubIssueOpened || githubIssuePayloadUrl || githubIssuePayload; + const disableGithubIssueCreateAnywayButton = githubIssueChecking || githubIssueOpened || githubIssuePayload; + const shouldShowGithubIssuePayloadPanel = Boolean(githubIssuePayload || githubIssuePayloadCopied); + const shouldShowGithubIssuePayloadInsideExistingInfo = Boolean( + githubIssueExists && (githubIssuePayload || githubIssuePayloadCopied || githubIssuePayloadUrl) + ); + const githubIssuesUrl = githubIssueExists ? buildGithubSearchUrl(buildIssueTitle(device)) : null; + + const renderGithubIssuePayloadContent = () => ( +
+ +