Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions installer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions installer/webui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions scripts/bootstrap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

6 changes: 5 additions & 1 deletion services/tuya_mqtt/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/tuya_mqtt/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
73 changes: 73 additions & 0 deletions services/tuya_mqtt/tests/test_tuya_poll.py
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 24 additions & 14 deletions services/tuya_mqtt/tuya_mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,23 +143,37 @@ 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")
publish_online_status(client, device, True, "Simulation aktiv")
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)
Expand All @@ -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")
Expand Down
Loading