Skip to content

Commit 69604ce

Browse files
neneonlineclaudemake-all
authored
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>
1 parent 35c1d52 commit 69604ce

5 files changed

Lines changed: 381 additions & 0 deletions

File tree

custom_components/tuya_local/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131
from .device import async_delete_device, get_device_id, setup_device
3232
from .helpers.device_config import get_config
33+
from .helpers.discovery import async_start_discovery, async_stop_discovery
3334
from .services import async_setup_services
3435

3536
_LOGGER = logging.getLogger(__name__)
@@ -998,6 +999,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
998999
"Setting up entry for device: %s",
9991000
device_id,
10001001
)
1002+
# Start background LAN rediscovery so a device that changes IP (e.g. after a
1003+
# DHCP lease change) is relocated and reconnected without manual
1004+
# reconfiguration. Safe to call repeatedly; only one sweeper is started.
1005+
await async_start_discovery(hass)
10011006
config = {**entry.data, **entry.options, "name": entry.title}
10021007
try:
10031008
device = await hass.async_add_executor_job(setup_device, hass, config)
@@ -1060,6 +1065,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
10601065
await async_delete_device(hass, config)
10611066
domain_data.pop(device_id, None)
10621067

1068+
# Stop the shared rediscovery sweeper once the last device is gone.
1069+
remaining = [
1070+
e
1071+
for e in hass.config_entries.async_entries(DOMAIN)
1072+
if e.entry_id != entry.entry_id
1073+
]
1074+
if not remaining:
1075+
async_stop_discovery(hass)
1076+
10631077
return True
10641078

10651079

custom_components/tuya_local/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
DOMAIN = "tuya_local"
22
DATA_STORE = "store"
3+
DATA_DISCOVERY = "discovery"
34

45
CONF_DEVICE_ID = "device_id"
56
CONF_LOCAL_KEY = "local_key"
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Active Tuya LAN rediscovery.
3+
4+
When a router hands out new DHCP leases (e.g. after a reboot) a Tuya device
5+
can change IP. The integration then keeps trying the stale ``host`` stored in
6+
the config entry, the device goes unavailable, and the user has to reconfigure
7+
it by hand.
8+
9+
Tuya devices do not all announce themselves unprompted -- in particular
10+
protocol 3.4/3.5 devices stay silent until they receive a discovery request
11+
broadcast to UDP port 7000, at which point they reply with their current IP.
12+
``tinytuya``'s scanner sends exactly that request, so ``tinytuya.find_device``
13+
locates a device by its id (``gwId``) regardless of how its IP changed. This is
14+
the same mechanism the config flow already uses via ``scan_for_device`` and the
15+
one ``localtuya`` uses to find devices in seconds.
16+
17+
This module periodically checks for *unreachable* configured devices and, for
18+
each, looks up its current IP by device id and updates the config entry's host
19+
in place. Updating the entry triggers the integration's existing reload, which
20+
reconnects the device on the new IP -- no manual reconfiguration, no cloud
21+
round-trip, history preserved. Reachable devices are never scanned, so there is
22+
no network traffic while everything is healthy.
23+
24+
References:
25+
- tinytuya scanner discovery request (port 7000 for v3.5 devices):
26+
https://github.com/jasonacox/tinytuya/blob/master/tinytuya/scanner.py
27+
- the integration's own config-flow scan: config_flow.scan_for_device
28+
"""
29+
30+
import logging
31+
from datetime import timedelta
32+
33+
import tinytuya
34+
from homeassistant.const import CONF_HOST
35+
from homeassistant.core import HomeAssistant, callback
36+
from homeassistant.helpers.event import async_track_time_interval
37+
38+
from ..const import CONF_DEVICE_ID, DATA_DISCOVERY, DOMAIN
39+
from .config import get_device_id
40+
41+
_LOGGER = logging.getLogger(__name__)
42+
43+
# How often to look for unreachable devices. Reachable devices are skipped, so
44+
# a healthy system generates no scan traffic; an unreachable device is normally
45+
# relocated on the first sweep after it drops.
46+
SWEEP_INTERVAL = timedelta(seconds=60)
47+
48+
49+
def _find_device(device_id):
50+
"""Locate a device by id on the LAN (blocking; run in executor).
51+
52+
Sends the Tuya discovery request and returns the scanner result dict
53+
(``{'ip': ..., 'id': ..., ...}``), or a blank result on any socket error.
54+
"""
55+
try:
56+
return tinytuya.find_device(dev_id=device_id)
57+
except OSError:
58+
return {"ip": None}
59+
60+
61+
class TuyaLANRediscovery:
62+
"""Periodically relocate unreachable Tuya devices by active scan."""
63+
64+
def __init__(self, hass: HomeAssistant) -> None:
65+
self._hass = hass
66+
self._unsub = None
67+
self._scanning = False
68+
69+
@callback
70+
def async_start(self) -> None:
71+
"""Begin periodic rediscovery sweeps."""
72+
if self._unsub is None:
73+
self._unsub = async_track_time_interval(
74+
self._hass, self._async_sweep, SWEEP_INTERVAL
75+
)
76+
77+
@callback
78+
def async_stop(self, event=None) -> None:
79+
"""Stop periodic rediscovery sweeps."""
80+
if self._unsub is not None:
81+
self._unsub()
82+
self._unsub = None
83+
84+
def _unreachable_entries(self):
85+
"""Yield (entry, device_id) for configured devices not returning state."""
86+
domain_data = self._hass.data.get(DOMAIN, {})
87+
for entry in self._hass.config_entries.async_entries(DOMAIN):
88+
device_id = entry.data.get(CONF_DEVICE_ID)
89+
if not device_id:
90+
continue
91+
bucket = domain_data.get(get_device_id(entry.data))
92+
device = bucket.get("device") if bucket else None
93+
# No device object yet (setup not complete / failed) or it has not
94+
# returned state recently -> treat as unreachable and worth a scan.
95+
if device is not None and device.has_returned_state:
96+
continue
97+
yield entry, device_id
98+
99+
async def _async_sweep(self, now=None) -> None:
100+
"""Scan for any unreachable devices and update changed hosts."""
101+
if self._scanning:
102+
return
103+
targets = list(self._unreachable_entries())
104+
if not targets:
105+
return
106+
107+
self._scanning = True
108+
try:
109+
for entry, device_id in targets:
110+
found = await self._hass.async_add_executor_job(_find_device, device_id)
111+
ip = found.get("ip") if found else None
112+
if not ip:
113+
continue
114+
current = {**entry.data, **entry.options}.get(CONF_HOST)
115+
if ip == current:
116+
continue
117+
# WARNING, not INFO: an IP change is a notable operational event
118+
# the user may want to see, and config entries commonly run at
119+
# log level WARNING (which would suppress INFO).
120+
_LOGGER.warning(
121+
"%s: LAN IP changed to %s (was %s); updating configuration",
122+
entry.title,
123+
ip,
124+
current,
125+
)
126+
# Write the new host wherever it currently takes effect: always
127+
# to data, and also to options when options carries the host
128+
# (the options flow stores it there, overriding data), so the
129+
# merged config actually changes and the entry reloads.
130+
new_options = entry.options
131+
if CONF_HOST in entry.options:
132+
new_options = {**entry.options, CONF_HOST: ip}
133+
self._hass.config_entries.async_update_entry(
134+
entry,
135+
data={**entry.data, CONF_HOST: ip},
136+
options=new_options,
137+
)
138+
finally:
139+
self._scanning = False
140+
141+
142+
async def async_start_discovery(hass: HomeAssistant) -> None:
143+
"""Start the shared LAN rediscovery sweeper if not already running."""
144+
domain_data = hass.data.setdefault(DOMAIN, {})
145+
if domain_data.get(DATA_DISCOVERY) is not None:
146+
return
147+
148+
rediscovery = TuyaLANRediscovery(hass)
149+
domain_data[DATA_DISCOVERY] = rediscovery
150+
rediscovery.async_start()
151+
152+
153+
@callback
154+
def async_stop_discovery(hass: HomeAssistant) -> None:
155+
"""Stop the shared LAN rediscovery sweeper if running."""
156+
domain_data = hass.data.get(DOMAIN, {})
157+
rediscovery = domain_data.pop(DATA_DISCOVERY, None)
158+
if rediscovery is not None:
159+
rediscovery.async_stop()

tests/test_config_flow.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ def prevent_task_creation(mocker):
4141
yield
4242

4343

44+
@pytest.fixture(autouse=True)
45+
def bypass_discovery(mocker):
46+
"""Don't open real LAN discovery sockets during setup in these tests.
47+
48+
The discovery listener is exercised directly in tests/test_discovery.py.
49+
"""
50+
mocker.patch(
51+
"custom_components.tuya_local.async_start_discovery",
52+
new=AsyncMock(),
53+
)
54+
yield
55+
56+
4457
@pytest.fixture
4558
def bypass_setup(mocker):
4659
"""Prevent actual setup of the integration after config flow."""

0 commit comments

Comments
 (0)