diff --git a/front/src/components/boxs/device-in-room/device-features/PilotWireModeDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/PilotWireModeDeviceFeature.jsx index 71bdb51f71..eb6096ac9b 100644 --- a/front/src/components/boxs/device-in-room/device-features/PilotWireModeDeviceFeature.jsx +++ b/front/src/components/boxs/device-in-room/device-features/PilotWireModeDeviceFeature.jsx @@ -41,6 +41,12 @@ const PilotWireModeDeviceFeature = ({ children, ...props }) => { + + diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/PilotWireModeDeviceValue.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/PilotWireModeDeviceValue.jsx new file mode 100644 index 0000000000..35af8b3e9c --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/PilotWireModeDeviceValue.jsx @@ -0,0 +1,14 @@ +import { Text } from 'preact-i18n'; + +const PilotWireModeDeviceValue = ({ deviceFeature }) => ( +
+ {deviceFeature.last_value === null && } + {deviceFeature.last_value !== null && ( + + )} +
+); + +export default PilotWireModeDeviceValue; diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx index ddc9e20eb5..d3748d5f05 100644 --- a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx @@ -18,6 +18,7 @@ import NoRecentValueBadge from './NoRecentValueBadge'; import TemperatureSensorDeviceValue from './TemperatureSensorDeviceValue'; import LevelSensorDeviceValue from './LevelSensorDeviceValue'; import PressureSensorDeviceValue from './PressureSensorDeviceValue'; +import PilotWireModeDeviceValue from './PilotWireModeDeviceValue'; const DISPLAY_BY_FEATURE_CATEGORY = { [DEVICE_FEATURE_CATEGORIES.MOTION_SENSOR]: MotionSensorDeviceValue, @@ -50,7 +51,8 @@ const DISPLAY_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.INDOOR_TEMPERATURE]: TemperatureSensorDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_RANGE_ESTIMATE]: DistanceSensorDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER]: DistanceSensorDeviceValue, - [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: PressureSensorDeviceValue + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: PressureSensorDeviceValue, + [DEVICE_FEATURE_TYPES.HEATER.PILOT_WIRE_MODE]: PilotWireModeDeviceValue }; const DEVICE_FEATURES_WITHOUT_EXPIRATION = [ diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index b9963df0df..8c48ffee6c 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,65 @@ "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.", + "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 +1281,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.", @@ -3339,7 +3395,9 @@ "off": "Aus", "frost-protection": "Frostschutz", "comfort_-1": "Komfort -1°C", - "comfort_-2": "Komfort -2°C" + "comfort_-2": "Komfort -2°C", + "programming": "Programm", + "thermostat": "Thermostat" } }, "siren": { @@ -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": { @@ -3456,7 +3514,9 @@ "2": "Öko", "3": "Komfort -1°C", "4": "Komfort -2°C", - "5": "Komfort" + "5": "Komfort", + "6": "Programm", + "7": "Thermostat" } }, "opening-sensor": { diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index aaf78b67d9..0e23648d2c 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,65 @@ "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.", + "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 +1176,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.", @@ -3339,7 +3395,9 @@ "off": "Off", "frost-protection": "Frost Protection", "comfort_-1": "Comfort -1°C", - "comfort_-2": "Comfort -2°C" + "comfort_-2": "Comfort -2°C", + "programming": "Programming", + "thermostat": "Thermostat" } }, "siren": { @@ -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": { @@ -3456,7 +3514,9 @@ "2": "Eco", "3": "Comfort -1°C", "4": "Comfort -2°C", - "5": "Comfort" + "5": "Comfort", + "6": "Programming", + "7": "Thermostat" } }, "opening-sensor": { diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 83f8999af4..7a412225e3 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,65 @@ "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.", + "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 +1411,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.", @@ -3339,7 +3395,9 @@ "off": "Off", "frost-protection": "Hors Gel", "comfort_-1": "Confort -1°C", - "comfort_-2": "Confort -2°C" + "comfort_-2": "Confort -2°C", + "programming": "Programmation", + "thermostat": "Thermostat" } }, "siren": { @@ -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": { @@ -3456,7 +3514,9 @@ "2": "Eco", "3": "Confort -1°C", "4": "Confort -2°C", - "5": "Confort" + "5": "Confort", + "6": "Programmation", + "7": "Thermostat" } }, "opening-sensor": { diff --git a/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx b/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx index 7e202253a0..385d4c30fb 100644 --- a/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx +++ b/front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx @@ -5,20 +5,231 @@ 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'; +import { + normalizeBoolean, + buildParamsMap, + getLocalPollDpsFromParams, + getUnknownDpsKeys, + getUnknownSpecificationCodes +} from './commons/deviceHelpers'; +import { + buildIssueTitle, + buildGithubSearchUrl, + checkGithubIssueExists, + createGithubIssueData, + createGithubUrl, + createEmptyGithubIssueUrl +} from './commons/githubIssue'; + +const ONLINE_RECENT_MINUTES = 5; +const LOCAL_POLL_FREQUENCY = 10 * 1000; +const CLOUD_POLL_FREQUENCY = 30 * 1000; + +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 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, + localPollDps: null, + githubIssueChecking: false, + githubIssueExists: false, + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: false }); } 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, + githubIssueChecking: false, + githubIssueExists: false, + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: false + }); + return; + } this.setState({ - device: nextProps.device + device: mergedNextDevice, + baselineDevice: shouldRefreshBaseline ? mergedNextDevice : 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, + localPollDps: null, + githubIssueExists: false, + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: false + }); + }; + updateName = e => { this.setState({ device: { @@ -37,19 +248,341 @@ class TuyaDeviceBox extends Component { }); }; + updateProtocol = e => { + const protocolVersion = e.target.value; + 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, + localPollDps: null + }); + }; + + pollLocal = async () => { + const currentDevice = this.state.device; + this.setState({ + localPollStatus: RequestStatus.Getting, + localPollError: null, + localPollProtocol: null, + localPollDps: null + }); + const params = Array.isArray(currentDevice.params) ? currentDevice.params : []; + const getParam = name => { + const found = params.find(param => param.name === name); + return found ? found.value : undefined; + }; + const tryProtocols = ['3.5', '3.4', '3.3', '3.1']; + const selectedProtocol = getParam('PROTOCOL_VERSION') || currentDevice.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: currentDevice.external_id + ? currentDevice.external_id.split(':')[1] || currentDevice.external_id + : undefined, + ip: getParam('IP_ADDRESS') || currentDevice.ip, + localKey: getParam('LOCAL_KEY') || currentDevice.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 baseDevice = latestDevice || currentDevice; + const baseParams = Array.isArray(baseDevice.params) ? [...baseDevice.params] : []; + const newParams = baseParams; + 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 }); + } + } + this.setState(prevState => { + const latestStateDevice = prevState.device || {}; + const latestStateParams = Array.isArray(latestStateDevice.params) ? latestStateDevice.params : []; + const mergedParams = [...newParams]; + + latestStateParams.forEach(param => { + const index = mergedParams.findIndex(baseParam => baseParam.name === param.name); + if (index >= 0) { + mergedParams[index] = { ...mergedParams[index], ...param }; + } else { + mergedParams.push(param); + } + }); + + return { + device: { + ...baseDevice, + ...latestStateDevice, + params: mergedParams + }, + localPollStatus: RequestStatus.Success, + localPollProtocol: null, + localPollValidation: { + ip: getParam('IP_ADDRESS') || currentDevice.ip || '', + protocol: usedProtocol || '', + localOverride: true + }, + localPollDps: result ? result.dps : null + }; + }); + } 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, + localPollDps: 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, + localPollDps: null + }); + }; + + handleCreateGithubIssue = async e => { + if (e && e.preventDefault) { + e.preventDefault(); + } + const { + githubIssueChecking, + githubIssueExists, + githubIssuePayload, + githubIssuePayloadUrl, + githubIssueOpened, + device, + localPollStatus, + localPollError, + localPollValidation, + localPollDps + } = this.state; + if (githubIssueChecking || githubIssueExists || githubIssuePayload || githubIssuePayloadUrl || githubIssueOpened) { + return; + } + const persistedLocalPollDps = getLocalPollDpsFromParams(device); + const effectiveLocalPollDps = localPollDps || persistedLocalPollDps; + const issueData = createGithubIssueData( + device, + localPollStatus, + localPollError, + localPollValidation, + effectiveLocalPollDps + ); + const issueUrl = issueData.url; + const issueTitle = buildIssueTitle(device); + const popup = window.open('about:blank', '_blank'); + if (popup) { + popup.opener = null; + popup.document.title = 'GitHub'; + popup.document.body.innerText = 'Searching for existing issues...'; + } + + this.setState({ githubIssueChecking: true }); + + let shouldOpenIssue = true; + try { + const exists = await checkGithubIssueExists(issueTitle); + if (exists) { + shouldOpenIssue = false; + this.setState({ githubIssueExists: true }); + } + } 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 + }); + return; + } + + if (issueData.truncated) { + closePopup(); + this.setState({ + githubIssuePayload: issueData.body, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: issueData.url, + githubIssueOpened: false + }); + return; + } + + this.setState({ + githubIssuePayload: null, + githubIssuePayloadCopied: false, + githubIssuePayloadUrl: null, + githubIssueOpened: true + }); + + if (popup) { + popup.location = issueUrl; + return; + } + window.open(issueUrl, '_blank'); + }; + + 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 { githubIssuePayloadUrl, githubIssueChecking, githubIssueExists, githubIssueOpened, device } = this.state; + if (!githubIssuePayloadUrl || githubIssueChecking || githubIssueExists || githubIssueOpened || !device) { + return; + } + const issueTitle = buildIssueTitle(device); + const popup = window.open('about:blank', '_blank'); + if (popup) { + popup.opener = null; + } + this.setState({ githubIssueChecking: true }); + let shouldOpenIssue = true; + try { + const exists = await checkGithubIssueExists(issueTitle); + if (exists) { + shouldOpenIssue = false; + if (popup && !popup.closed) { + popup.close(); + } + this.setState({ githubIssueExists: true }); + } + } 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 }); + }; + saveDevice = async () => { this.setState({ loading: true, errorMessage: null }); try { - const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device); + const payload = { + ...this.state.device, + poll_frequency: getLocalConfig(this.state.device).localOverride ? LOCAL_POLL_FREQUENCY : CLOUD_POLL_FREQUENCY + }; + 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 +634,168 @@ class TuyaDeviceBox extends Component { alreadyCreatedButton, housesWithRooms }, - { device, loading, errorMessage, tooMuchStatesError, statesNumber } + { + device, + loading, + errorMessage, + tooMuchStatesError, + statesNumber, + localPollStatus, + localPollError, + localPollProtocol, + localPollValidation, + localPollDps, + githubIssueChecking, + githubIssueExists, + githubIssuePayload, + githubIssuePayloadCopied, + githubIssuePayloadUrl, + githubIssueOpened + } ) { 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 || '-'; + const githubIssuesUrl = githubIssueExists ? buildGithubSearchUrl(buildIssueTitle(device)) : null; + const persistedLocalPollDps = getLocalPollDpsFromParams(device); + const effectiveLocalPollDps = localOverride ? localPollDps || persistedLocalPollDps : null; + const unknownLocalDpsKeys = getUnknownDpsKeys(effectiveLocalPollDps, device.features, device); + const unknownSpecCodes = getUnknownSpecificationCodes(device.specifications, device.features, device); + const unknownKeys = effectiveLocalPollDps ? unknownLocalDpsKeys : unknownSpecCodes; + const hasPartialSupport = validModel && unknownKeys.length > 0; + const partialCountLabelId = + isDiscoverPage && !device.created_at + ? 'integration.tuya.device.partialFeaturesCountDiscover' + : 'integration.tuya.device.partialFeaturesCount'; + const disableGithubIssueButton = + githubIssueChecking || githubIssueExists || githubIssueOpened || githubIssuePayloadUrl || githubIssuePayload; + + const renderGithubIssueButton = (labelId, extraClass = '') => ( + + + + ); + + const renderGithubIssueAction = () => ( +
+
+ {renderGithubIssueButton('integration.tuya.device.createGithubIssue', 'ml-sm-auto')} +
+ {githubIssueExists ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); + + const renderGithubIssuePrepAlert = titleId => ( +
+
+ +
+
+ +
+
+ ); + + const renderGithubIssuePayloadInfo = () => { + if (!githubIssuePayload && !githubIssuePayloadCopied) { + return null; + } + return ( +
+
+ +