From 41e9f55378b579a79dbe1115e0e3116b7b5b300c Mon Sep 17 00:00:00 2001 From: Developer-Simon Date: Tue, 22 Sep 2026 18:03:04 +0200 Subject: [PATCH 1/3] fix(tuya): retry once on a stale persistent socket before reporting offline The Tuya valve bridge polls each device over a persistent TinyTuya socket. Production logs show that socket returning "Unexpected Payload from Device" (Err 904) on roughly every second poll, right after a preceding successful poll on the same connection - a fresh reconnect always succeeds immediately after. poll_one previously treated any single failed poll as a hard offline flip, so this alternation showed up as the valve flapping online/offline every poll interval. Now a failed status() call reconnects and retries once in the same cycle before giving up. Co-Authored-By: Claude Sonnet 5 --- services/tuya_mqtt/tests/test_tuya_poll.py | 73 ++++++++++++++++++++++ services/tuya_mqtt/tuya_mqtt.py | 38 ++++++----- 2 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 services/tuya_mqtt/tests/test_tuya_poll.py diff --git a/services/tuya_mqtt/tests/test_tuya_poll.py b/services/tuya_mqtt/tests/test_tuya_poll.py new file mode 100644 index 0000000..dd79728 --- /dev/null +++ b/services/tuya_mqtt/tests/test_tuya_poll.py @@ -0,0 +1,73 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import pytest + +import tuya_mqtt + + +class FakeMqttClient: + def __init__(self): + self.published = [] + + def publish(self, topic, payload=None, qos=0, retain=True): + self.published.append((topic, payload)) + + +def make_device(**overrides): + cfg = tuya_mqtt.TuyaDeviceConfig( + id="heizungs_ventil", + name="Heizungsventil", + device_id="dev123", + local_key="key123", + ip="192.0.2.50", + **overrides, + ) + return tuya_mqtt.TuyaDevice(cfg) + + +class FakeTuyaDevice: + """Zweiter status()-Aufruf auf demselben Objekt schlägt fehl, wie es das + TinyTuya-'Unexpected Payload'-Muster auf dem persistenten Socket zeigt.""" + + def __init__(self, fail_status=False): + self.fail_status = fail_status + self.status_calls = 0 + + def set_version(self, version): + pass + + def set_socketPersistent(self, persistent): + pass + + def status(self): + self.status_calls += 1 + if self.fail_status: + return {"Error": "Unexpected Payload from Device", "Err": "904", "Payload": None} + return {"dps": {"1": True}} + + +def test_poll_one_retries_once_after_stale_socket_error(monkeypatch): + device = make_device() + connections = [FakeTuyaDevice(fail_status=True), FakeTuyaDevice(fail_status=False)] + monkeypatch.setattr(tuya_mqtt, "connect_device", lambda cfg: connections.pop(0)) + device.device = connections.pop(0) + client = FakeMqttClient() + + tuya_mqtt.poll_one(device, client, simulation_active=False) + + topics = dict(client.published) + assert topics["outstation/heizungs_ventil/switch"] == "ON" + assert topics["outstation/heizungs_ventil/status/online"] == "1" + + +def test_poll_one_raises_when_retry_also_fails(monkeypatch): + device = make_device() + device.device = FakeTuyaDevice(fail_status=True) + monkeypatch.setattr(tuya_mqtt, "connect_device", lambda cfg: FakeTuyaDevice(fail_status=True)) + client = FakeMqttClient() + + with pytest.raises(RuntimeError): + tuya_mqtt.poll_one(device, client, simulation_active=False) diff --git a/services/tuya_mqtt/tuya_mqtt.py b/services/tuya_mqtt/tuya_mqtt.py index a23e2b9..a43fd12 100644 --- a/services/tuya_mqtt/tuya_mqtt.py +++ b/services/tuya_mqtt/tuya_mqtt.py @@ -143,6 +143,14 @@ def publish_device_discovery(client: mqtt.Client, device: TuyaDevice, node_devic ) +def connect_device(cfg: TuyaDeviceConfig) -> Any: + device = tinytuya.Device(cfg.device_id, cfg.ip, cfg.local_key) + device.set_version(cfg.version) + device.set_socketPersistent(True) + log.info("[%s] Tuya-Gerät initialisiert: %s", cfg.id, cfg.device_id) + return device + + def poll_one(device: TuyaDevice, client: mqtt.Client, simulation_active: bool) -> None: if simulation_active: publish(client, device, "switch", "ON" if device.simulated_switch_state else "OFF") @@ -150,16 +158,22 @@ def poll_one(device: TuyaDevice, client: mqtt.Client, simulation_active: bool) - return if device.device is None: - device.device = tinytuya.Device( - device.cfg.device_id, device.cfg.ip, device.cfg.local_key - ) - device.device.set_version(device.cfg.version) - device.device.set_socketPersistent(True) - log.info("[%s] Tuya-Gerät initialisiert: %s", device.cfg.id, device.cfg.device_id) + device.device = connect_device(device.cfg) - status = device.device.status() - if "Error" in status: - raise RuntimeError(f"Statusfehler: {status}") + try: + status = device.device.status() + if "Error" in status: + raise RuntimeError(f"Statusfehler: {status}") + except Exception as exc: + # Der persistente Socket verwirft nach einer erfolgreichen Abfrage + # gelegentlich unaufgefordert nachgeschobene Payloads (TinyTuya + # "Unexpected Payload"/Err 904); ein frischer Verbindungsaufbau + # behebt das zuverlässig, siehe journalctl-Muster auf dem Node. + log.info("[%s] Abfrage fehlgeschlagen (%s), verbinde neu und versuche erneut", device.cfg.id, exc) + device.device = connect_device(device.cfg) + status = device.device.status() + if "Error" in status: + raise RuntimeError(f"Statusfehler: {status}") dps = status.get("dps", {}) log.info("[%s] Roher Gerätestatus (dps): %s", device.cfg.id, dps) @@ -182,11 +196,7 @@ def set_switch(device: TuyaDevice, client: mqtt.Client, active: bool, simulation device.simulated_switch_state = active else: if device.device is None: - device.device = tinytuya.Device( - device.cfg.device_id, device.cfg.ip, device.cfg.local_key - ) - device.device.set_version(device.cfg.version) - device.device.set_socketPersistent(True) + device.device = connect_device(device.cfg) result = device.device.set_value(device.cfg.switch_dp, active) log.info("[%s] Tuya-Antwort: %s", device.cfg.id, result) publish(client, device, "switch", "ON" if active else "OFF") From 6473a8b09aca3069354bf979a028d29ecec42814 Mon Sep 17 00:00:00 2001 From: energy-node-bot Date: Tue, 22 Sep 2026 16:03:25 +0000 Subject: [PATCH 2/3] chore(release): bump component versions --- services/tuya_mqtt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/tuya_mqtt/manifest.json b/services/tuya_mqtt/manifest.json index ffe0f44..7297ee6 100644 --- a/services/tuya_mqtt/manifest.json +++ b/services/tuya_mqtt/manifest.json @@ -1,6 +1,6 @@ { "service_id": "tuya", - "version": "0.4.0", + "version": "0.4.1", "unit": "tuya.service", "bootstrap_step": "85", "schema": "config.schema.json", From c94a319a2293cd770a4d1383961a1ac7e21ff76c Mon Sep 17 00:00:00 2001 From: energy-node-bot Date: Tue, 22 Sep 2026 16:03:32 +0000 Subject: [PATCH 3/3] docs(changelog): update changelogs --- installer/CHANGELOG.md | 1 + installer/webui/CHANGELOG.md | 1 + scripts/bootstrap/CHANGELOG.md | 1 + services/tuya_mqtt/CHANGELOG.md | 6 +++++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/installer/CHANGELOG.md b/installer/CHANGELOG.md index 0ea3c71..6c46d34 100644 --- a/installer/CHANGELOG.md +++ b/installer/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixes - **installer:** restart service units on update and record the installed manifest (#43) (0f2aec2) +- **installer:** make redeploy, repair and repo-built bundles install cleanly (#46) (bf1fe10) - **installer:** expand ~ in the repo package path (873bdda) - **installer:** replace remote files the SSH user cannot open for writing (eca0198) - **installer:** give step 20 its MQTT arguments from the node on redeploy and repair (bb4cd40) diff --git a/installer/webui/CHANGELOG.md b/installer/webui/CHANGELOG.md index 2bd4faa..6baaaf1 100644 --- a/installer/webui/CHANGELOG.md +++ b/installer/webui/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes +- **installer:** make redeploy, repair and repo-built bundles install cleanly (#46) (bf1fe10) - **webui:** show the error detail of a failed run (5017432) - **installer:** give step 20 its MQTT arguments from the node on redeploy and repair (bb4cd40) - **installer:** add texts for every fault code the bootstrap steps emit (23a64c3) diff --git a/scripts/bootstrap/CHANGELOG.md b/scripts/bootstrap/CHANGELOG.md index 70121ed..f490c90 100644 --- a/scripts/bootstrap/CHANGELOG.md +++ b/scripts/bootstrap/CHANGELOG.md @@ -15,6 +15,7 @@ - **bootstrap:** make steps 20, 40, 65, 70 and the diagnosis work on a real node (#37) (11eef9d) - **installer:** restart service units on update and record the installed manifest (#43) (0f2aec2) +- **installer:** make redeploy, repair and repo-built bundles install cleanly (#46) (bf1fe10) - **bootstrap:** ignore a commented-out userspace-networking flag in step 40 (a1aeee1) - **bootstrap:** let step 70 keep an installed Caddy when the bundle has no Caddy pack (f8fbc8d) diff --git a/services/tuya_mqtt/CHANGELOG.md b/services/tuya_mqtt/CHANGELOG.md index 3d7c996..91004d3 100644 --- a/services/tuya_mqtt/CHANGELOG.md +++ b/services/tuya_mqtt/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog -## v0.4.0 (2026-09-21) +## v0.4.1 (2026-09-22) ### Features - **services:** give every service its own version and changelog (#45) (5bc91b8) +### Fixes + +- **tuya:** retry once on a stale persistent socket before reporting offline (41e9f55) + ## v0.3.2 (2026-09-15) ### Features