Просмотр исходного кода

feat: auto-update device IP via active Tuya LAN rediscovery (#5532)

When a router's DHCP reassigns a device's IP, tuya_local keeps using the
stale host and the device goes unavailable until manually reconfigured.

Add a rediscovery sweeper (helpers/discovery.py) that periodically checks
for unreachable configured devices and, for each, looks up its current IP
by device id via tinytuya.find_device() (in the executor) and updates the
config entry host in place. The existing update-listener reload then
reconnects the device on the new IP - no manual reconfig, no cloud
round-trip, history preserved.

Active discovery (not passive listening) is required because Tuya 3.4/3.5
devices stay silent until they receive a discovery request on UDP 7000;
tinytuya's scanner sends it, so find_device() locates them by gwId. This
reuses the exact call the config flow already ships as scan_for_device,
and matches how localtuya finds these devices in seconds. Verified on
real protocol-3.5 thermostats: all devices answer the probe within
seconds, while a passive listener saw nothing.

Reachable devices are never scanned, so a healthy system generates no scan
traffic; an unreachable device is relocated on the next sweep. The host
update writes to data and (when present) options, since the options flow
stores host there. Started on first entry setup, stopped on last unload.
Adds tests/test_discovery.py and a bypass_discovery fixture for existing
setup tests.

Co-authored-by: neneonline <2820882+neneonline@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jason Rumney <make-all@users.noreply.github.com>
neneonline 1 день назад
Родитель
Сommit
69604ce66a

+ 14 - 0
custom_components/tuya_local/__init__.py

@@ -30,6 +30,7 @@ from .const import (
 )
 from .device import async_delete_device, get_device_id, setup_device
 from .helpers.device_config import get_config
+from .helpers.discovery import async_start_discovery, async_stop_discovery
 from .services import async_setup_services
 
 _LOGGER = logging.getLogger(__name__)
@@ -998,6 +999,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
         "Setting up entry for device: %s",
         device_id,
     )
+    # Start background LAN rediscovery so a device that changes IP (e.g. after a
+    # DHCP lease change) is relocated and reconnected without manual
+    # reconfiguration. Safe to call repeatedly; only one sweeper is started.
+    await async_start_discovery(hass)
     config = {**entry.data, **entry.options, "name": entry.title}
     try:
         device = await hass.async_add_executor_job(setup_device, hass, config)
@@ -1060,6 +1065,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
     await async_delete_device(hass, config)
     domain_data.pop(device_id, None)
 
+    # Stop the shared rediscovery sweeper once the last device is gone.
+    remaining = [
+        e
+        for e in hass.config_entries.async_entries(DOMAIN)
+        if e.entry_id != entry.entry_id
+    ]
+    if not remaining:
+        async_stop_discovery(hass)
+
     return True
 
 

+ 1 - 0
custom_components/tuya_local/const.py

@@ -1,5 +1,6 @@
 DOMAIN = "tuya_local"
 DATA_STORE = "store"
+DATA_DISCOVERY = "discovery"
 
 CONF_DEVICE_ID = "device_id"
 CONF_LOCAL_KEY = "local_key"

+ 159 - 0
custom_components/tuya_local/helpers/discovery.py

@@ -0,0 +1,159 @@
+"""
+Active Tuya LAN rediscovery.
+
+When a router hands out new DHCP leases (e.g. after a reboot) a Tuya device
+can change IP. The integration then keeps trying the stale ``host`` stored in
+the config entry, the device goes unavailable, and the user has to reconfigure
+it by hand.
+
+Tuya devices do not all announce themselves unprompted -- in particular
+protocol 3.4/3.5 devices stay silent until they receive a discovery request
+broadcast to UDP port 7000, at which point they reply with their current IP.
+``tinytuya``'s scanner sends exactly that request, so ``tinytuya.find_device``
+locates a device by its id (``gwId``) regardless of how its IP changed. This is
+the same mechanism the config flow already uses via ``scan_for_device`` and the
+one ``localtuya`` uses to find devices in seconds.
+
+This module periodically checks for *unreachable* configured devices and, for
+each, looks up its current IP by device id and updates the config entry's host
+in place. Updating the entry triggers the integration's existing reload, which
+reconnects the device on the new IP -- no manual reconfiguration, no cloud
+round-trip, history preserved. Reachable devices are never scanned, so there is
+no network traffic while everything is healthy.
+
+References:
+- tinytuya scanner discovery request (port 7000 for v3.5 devices):
+  https://github.com/jasonacox/tinytuya/blob/master/tinytuya/scanner.py
+- the integration's own config-flow scan: config_flow.scan_for_device
+"""
+
+import logging
+from datetime import timedelta
+
+import tinytuya
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.event import async_track_time_interval
+
+from ..const import CONF_DEVICE_ID, DATA_DISCOVERY, DOMAIN
+from .config import get_device_id
+
+_LOGGER = logging.getLogger(__name__)
+
+# How often to look for unreachable devices. Reachable devices are skipped, so
+# a healthy system generates no scan traffic; an unreachable device is normally
+# relocated on the first sweep after it drops.
+SWEEP_INTERVAL = timedelta(seconds=60)
+
+
+def _find_device(device_id):
+    """Locate a device by id on the LAN (blocking; run in executor).
+
+    Sends the Tuya discovery request and returns the scanner result dict
+    (``{'ip': ..., 'id': ..., ...}``), or a blank result on any socket error.
+    """
+    try:
+        return tinytuya.find_device(dev_id=device_id)
+    except OSError:
+        return {"ip": None}
+
+
+class TuyaLANRediscovery:
+    """Periodically relocate unreachable Tuya devices by active scan."""
+
+    def __init__(self, hass: HomeAssistant) -> None:
+        self._hass = hass
+        self._unsub = None
+        self._scanning = False
+
+    @callback
+    def async_start(self) -> None:
+        """Begin periodic rediscovery sweeps."""
+        if self._unsub is None:
+            self._unsub = async_track_time_interval(
+                self._hass, self._async_sweep, SWEEP_INTERVAL
+            )
+
+    @callback
+    def async_stop(self, event=None) -> None:
+        """Stop periodic rediscovery sweeps."""
+        if self._unsub is not None:
+            self._unsub()
+            self._unsub = None
+
+    def _unreachable_entries(self):
+        """Yield (entry, device_id) for configured devices not returning state."""
+        domain_data = self._hass.data.get(DOMAIN, {})
+        for entry in self._hass.config_entries.async_entries(DOMAIN):
+            device_id = entry.data.get(CONF_DEVICE_ID)
+            if not device_id:
+                continue
+            bucket = domain_data.get(get_device_id(entry.data))
+            device = bucket.get("device") if bucket else None
+            # No device object yet (setup not complete / failed) or it has not
+            # returned state recently -> treat as unreachable and worth a scan.
+            if device is not None and device.has_returned_state:
+                continue
+            yield entry, device_id
+
+    async def _async_sweep(self, now=None) -> None:
+        """Scan for any unreachable devices and update changed hosts."""
+        if self._scanning:
+            return
+        targets = list(self._unreachable_entries())
+        if not targets:
+            return
+
+        self._scanning = True
+        try:
+            for entry, device_id in targets:
+                found = await self._hass.async_add_executor_job(_find_device, device_id)
+                ip = found.get("ip") if found else None
+                if not ip:
+                    continue
+                current = {**entry.data, **entry.options}.get(CONF_HOST)
+                if ip == current:
+                    continue
+                # WARNING, not INFO: an IP change is a notable operational event
+                # the user may want to see, and config entries commonly run at
+                # log level WARNING (which would suppress INFO).
+                _LOGGER.warning(
+                    "%s: LAN IP changed to %s (was %s); updating configuration",
+                    entry.title,
+                    ip,
+                    current,
+                )
+                # Write the new host wherever it currently takes effect: always
+                # to data, and also to options when options carries the host
+                # (the options flow stores it there, overriding data), so the
+                # merged config actually changes and the entry reloads.
+                new_options = entry.options
+                if CONF_HOST in entry.options:
+                    new_options = {**entry.options, CONF_HOST: ip}
+                self._hass.config_entries.async_update_entry(
+                    entry,
+                    data={**entry.data, CONF_HOST: ip},
+                    options=new_options,
+                )
+        finally:
+            self._scanning = False
+
+
+async def async_start_discovery(hass: HomeAssistant) -> None:
+    """Start the shared LAN rediscovery sweeper if not already running."""
+    domain_data = hass.data.setdefault(DOMAIN, {})
+    if domain_data.get(DATA_DISCOVERY) is not None:
+        return
+
+    rediscovery = TuyaLANRediscovery(hass)
+    domain_data[DATA_DISCOVERY] = rediscovery
+    rediscovery.async_start()
+
+
+@callback
+def async_stop_discovery(hass: HomeAssistant) -> None:
+    """Stop the shared LAN rediscovery sweeper if running."""
+    domain_data = hass.data.get(DOMAIN, {})
+    rediscovery = domain_data.pop(DATA_DISCOVERY, None)
+    if rediscovery is not None:
+        rediscovery.async_stop()

+ 13 - 0
tests/test_config_flow.py

@@ -41,6 +41,19 @@ def prevent_task_creation(mocker):
     yield
 
 
+@pytest.fixture(autouse=True)
+def bypass_discovery(mocker):
+    """Don't open real LAN discovery sockets during setup in these tests.
+
+    The discovery listener is exercised directly in tests/test_discovery.py.
+    """
+    mocker.patch(
+        "custom_components.tuya_local.async_start_discovery",
+        new=AsyncMock(),
+    )
+    yield
+
+
 @pytest.fixture
 def bypass_setup(mocker):
     """Prevent actual setup of the integration after config flow."""

+ 194 - 0
tests/test_discovery.py

@@ -0,0 +1,194 @@
+"""Tests for the active Tuya LAN rediscovery sweeper."""
+
+import logging
+
+import pytest
+from homeassistant.const import CONF_HOST
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_LOCAL_KEY,
+    CONF_POLL_ONLY,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DATA_DISCOVERY,
+    DOMAIN,
+)
+from custom_components.tuya_local.helpers import discovery
+from custom_components.tuya_local.helpers.discovery import (
+    TuyaLANRediscovery,
+    async_start_discovery,
+    async_stop_discovery,
+)
+
+TESTKEY = ")<jO<@)'P1|kR$Kd"
+DEVID = "bf1234567890abcdef"
+
+
+@pytest.fixture(autouse=True)
+def auto_enable_custom_integrations(enable_custom_integrations):
+    yield
+
+
+def _make_entry(hass, host="192.168.1.10", options=None):
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        version=13,
+        minor_version=20,
+        title="thermostat",
+        data={
+            CONF_DEVICE_ID: DEVID,
+            CONF_HOST: host,
+            CONF_LOCAL_KEY: TESTKEY,
+            CONF_POLL_ONLY: False,
+            CONF_PROTOCOL_VERSION: "auto",
+            CONF_TYPE: "polytherm_polyalpha_thermostat",
+        },
+        options=options or {},
+    )
+    entry.add_to_hass(hass)
+    return entry
+
+
+def _set_device(hass, returned_state, device_id=DEVID):
+    """Register a fake device object in hass.data under the device id."""
+    device = type("Dev", (), {"has_returned_state": returned_state})()
+    hass.data.setdefault(DOMAIN, {})[device_id] = {"device": device}
+    return device
+
+
+@pytest.mark.asyncio
+async def test_sweep_updates_unreachable_changed_host(hass, caplog, mocker):
+    """An unreachable device gets relocated, its host updated, and it's logged at WARNING."""
+    entry = _make_entry(hass, host="192.168.1.10")
+    _set_device(hass, returned_state=False)
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._find_device",
+        return_value={"ip": "192.168.1.55", "id": DEVID},
+    )
+
+    with caplog.at_level(
+        logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
+    ):
+        await TuyaLANRediscovery(hass)._async_sweep()
+        await hass.async_block_till_done()
+
+    assert entry.data[CONF_HOST] == "192.168.1.55"
+    # The IP change must be visible even when the entry runs at WARNING.
+    assert "192.168.1.55" in caplog.text
+    assert "192.168.1.10" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_sweep_skips_reachable_device(hass, mocker):
+    """A device that is returning state is never scanned."""
+    entry = _make_entry(hass, host="192.168.1.10")
+    _set_device(hass, returned_state=True)
+    find = mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._find_device",
+        return_value={"ip": "192.168.1.55"},
+    )
+
+    await TuyaLANRediscovery(hass)._async_sweep()
+    await hass.async_block_till_done()
+
+    find.assert_not_called()
+    assert entry.data[CONF_HOST] == "192.168.1.10"
+
+
+@pytest.mark.asyncio
+async def test_sweep_no_change_when_ip_same(hass, mocker):
+    """If the scan returns the current IP, no entry update happens."""
+    entry = _make_entry(hass, host="192.168.1.10")
+    _set_device(hass, returned_state=False)
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._find_device",
+        return_value={"ip": "192.168.1.10"},
+    )
+    update = mocker.spy(hass.config_entries, "async_update_entry")
+
+    await TuyaLANRediscovery(hass)._async_sweep()
+    await hass.async_block_till_done()
+
+    update.assert_not_called()
+    assert entry.data[CONF_HOST] == "192.168.1.10"
+
+
+@pytest.mark.asyncio
+async def test_sweep_handles_not_found(hass, mocker):
+    """A scan that finds nothing must not raise or change anything."""
+    entry = _make_entry(hass, host="192.168.1.10")
+    _set_device(hass, returned_state=False)
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._find_device",
+        return_value={"ip": None},
+    )
+    update = mocker.spy(hass.config_entries, "async_update_entry")
+
+    await TuyaLANRediscovery(hass)._async_sweep()
+    await hass.async_block_till_done()
+
+    update.assert_not_called()
+    assert entry.data[CONF_HOST] == "192.168.1.10"
+
+
+@pytest.mark.asyncio
+async def test_sweep_updates_host_stored_in_options(hass, mocker):
+    """When the effective host lives in options, the update targets options."""
+    entry = _make_entry(hass, host="10.0.0.1", options={CONF_HOST: "192.168.1.103"})
+    _set_device(hass, returned_state=False)
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._find_device",
+        return_value={"ip": "192.168.1.55"},
+    )
+
+    await TuyaLANRediscovery(hass)._async_sweep()
+    await hass.async_block_till_done()
+
+    assert entry.options[CONF_HOST] == "192.168.1.55"
+    assert entry.data[CONF_HOST] == "192.168.1.55"
+
+
+@pytest.mark.asyncio
+async def test_sweep_scans_when_no_device_object(hass, mocker):
+    """An entry with no device object yet (failed setup) is still scanned."""
+    entry = _make_entry(hass, host="192.168.1.10")
+    hass.data.setdefault(DOMAIN, {})  # no device bucket registered
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._find_device",
+        return_value={"ip": "192.168.1.77"},
+    )
+
+    await TuyaLANRediscovery(hass)._async_sweep()
+    await hass.async_block_till_done()
+
+    assert entry.data[CONF_HOST] == "192.168.1.77"
+
+
+@pytest.mark.asyncio
+async def test_start_is_idempotent_and_stop_cancels(hass, mocker):
+    """async_start_discovery schedules one interval; stop cancels it."""
+    unsub = mocker.MagicMock()
+    track = mocker.patch(
+        "custom_components.tuya_local.helpers.discovery.async_track_time_interval",
+        return_value=unsub,
+    )
+
+    await async_start_discovery(hass)
+    rediscovery = hass.data[DOMAIN][DATA_DISCOVERY]
+    assert isinstance(rediscovery, TuyaLANRediscovery)
+    assert track.call_count == 1
+
+    # Second call must not schedule another interval (singleton).
+    await async_start_discovery(hass)
+    assert track.call_count == 1
+
+    async_stop_discovery(hass)
+    unsub.assert_called_once()
+    assert DATA_DISCOVERY not in hass.data[DOMAIN]
+
+
+def test_module_exposes_expected_interval():
+    """Guard the sweep cadence against accidental change."""
+    assert discovery.SWEEP_INTERVAL.total_seconds() == 60