Skip to content

Commit cafa367

Browse files
neneonlineclaude
andauthored
feat: discover new Tuya devices on the LAN (#5605)
Third increment for #5526. Extends the discovery service's periodic scan to a single tinytuya.deviceScan that (a) checks product ids for configured devices (folding in the per-device check from #5573) and (b) raises an integration_discovery flow for each unconfigured device found, so it surfaces in Home Assistant for one-click setup with the built-in per-unique_id ignore. Adds ConfigFlowHandler.async_step_integration_discovery, which reuses the existing manual-setup prefill (id/ip/version from discovery; the user supplies the local key) and aborts if the device is already configured or ignored. Keyed on gwId. The fast IP-rediscovery sweep from #5532 is unchanged. Co-authored-by: neneonline <2820882+neneonline@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6e0be58 commit cafa367

4 files changed

Lines changed: 261 additions & 63 deletions

File tree

custom_components/tuya_local/config_flow.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ def init_cloud(self):
7070
if self.cloud is None:
7171
self.cloud = Cloud(self.hass)
7272

73+
async def async_step_integration_discovery(self, discovery_info):
74+
"""Handle a device found on the LAN by the background scanner.
75+
76+
Pre-fills the manual setup form with the discovered id/ip/version; the
77+
user still supplies the local key. Aborts if the device is already
78+
configured or has been ignored.
79+
"""
80+
device_id = discovery_info.get(CONF_DEVICE_ID)
81+
await self.async_set_unique_id(device_id)
82+
self._abort_if_unique_id_configured()
83+
# Reuse the cloud-device plumbing that async_step_local reads for its
84+
# form defaults; the local key is not known from discovery.
85+
self.__cloud_device = {
86+
"id": device_id,
87+
"ip": discovery_info.get(CONF_HOST),
88+
"version": discovery_info.get("version"),
89+
"local_product_id": discovery_info.get("product_id"),
90+
CONF_LOCAL_KEY: "",
91+
}
92+
self.context["title_placeholders"] = {
93+
"name": discovery_info.get(CONF_HOST) or device_id
94+
}
95+
return await self.async_step_local()
96+
7397
async def async_step_user(self, user_input=None):
7498
errors = {}
7599

custom_components/tuya_local/helpers/discovery.py

Lines changed: 97 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Active Tuya LAN discovery for configured devices.
2+
Active Tuya LAN discovery.
33
44
When a router hands out new DHCP leases (e.g. after a reboot) a Tuya device
55
can change IP. The integration then keeps trying the stale ``host`` stored in
@@ -9,22 +9,24 @@
99
Tuya devices do not all announce themselves unprompted -- in particular
1010
protocol 3.4/3.5 devices stay silent until they receive a discovery request
1111
broadcast to UDP port 7000, at which point they reply with their id (``gwId``),
12-
current IP and ``product_id``. ``tinytuya``'s scanner sends exactly that request,
13-
so ``tinytuya.find_device`` locates a device by its id regardless of how its IP
14-
changed. This is the same mechanism the config flow already uses via
12+
current IP and ``productKey``. ``tinytuya``'s scanner sends exactly that request,
13+
so ``tinytuya.find_device``/``tinytuya.deviceScan`` locate devices regardless of
14+
how their IP changed. This is the same mechanism the config flow already uses via
1515
``scan_for_device`` and the one ``localtuya`` uses to find devices in seconds.
1616
17-
This module runs two active-scan tasks for the configured devices:
17+
This module runs two active-scan tasks:
1818
19-
- a fast sweep (every ``SWEEP_INTERVAL``) that relocates *unreachable* devices:
20-
it looks up the current IP by device id and updates the config entry's host in
21-
place. The existing update-listener reload then reconnects the device on the
22-
new IP -- no manual reconfiguration, no cloud round-trip, history preserved.
19+
- a fast sweep (every ``SWEEP_INTERVAL``) that relocates *unreachable* configured
20+
devices: it looks up the current IP by device id and updates the config entry's
21+
host in place. The existing update-listener reload then reconnects the device on
22+
the new IP -- no manual reconfiguration, no cloud round-trip, history preserved.
2323
Reachable devices are never scanned, so there is no traffic while healthy.
24-
- a slower scan (every ``SCAN_INTERVAL``) that logs, once per device per HA
25-
start, when a configured device reports a ``product_id`` that its config file
26-
does not list under ``products`` -- surfacing devices that may only be
27-
partially supported so the config can be improved.
24+
- a slower full scan (every ``SCAN_INTERVAL``) that, from a single
25+
``deviceScan``: (a) warns, once per device per HA start, when a *configured*
26+
device reports a ``productKey`` its config file does not list under
27+
``products`` (so the config can be improved); and (b) raises an
28+
``integration_discovery`` flow for each *unconfigured* device found, so it
29+
surfaces in Home Assistant for one-click setup (with the built-in ignore).
2830
2931
References:
3032
- tinytuya scanner discovery request (port 7000 for v3.5 devices):
@@ -36,6 +38,7 @@
3638
from datetime import timedelta
3739

3840
import tinytuya
41+
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
3942
from homeassistant.const import CONF_HOST
4043
from homeassistant.core import HomeAssistant, callback
4144
from homeassistant.helpers.event import async_track_time_interval
@@ -51,8 +54,8 @@
5154
# relocated on the first sweep after it drops.
5255
SWEEP_INTERVAL = timedelta(seconds=60)
5356

54-
# How often to scan configured devices to check their product id against the
55-
# config file. Diagnostic only, so it runs infrequently.
57+
# How often to run the full network scan (product-id check + new-device
58+
# discovery). Infrequent, since neither action is time critical.
5659
SCAN_INTERVAL = timedelta(minutes=10)
5760

5861

@@ -69,8 +72,20 @@ def _find_device(device_id):
6972
return {"ip": None}
7073

7174

75+
def _scan_all():
76+
"""Scan the LAN for all Tuya devices (blocking; run in executor).
77+
78+
Returns tinytuya's dict keyed by IP, each value carrying ``gwId``,
79+
``productKey`` and ``version``; an empty dict on any socket error.
80+
"""
81+
try:
82+
return tinytuya.deviceScan(verbose=False, poll=False)
83+
except OSError:
84+
return {}
85+
86+
7287
class TuyaLANRediscovery:
73-
"""Active LAN discovery for configured Tuya devices."""
88+
"""Active LAN discovery for Tuya devices."""
7489

7590
def __init__(self, hass: HomeAssistant) -> None:
7691
self._hass = hass
@@ -79,6 +94,8 @@ def __init__(self, hass: HomeAssistant) -> None:
7994
self._scanning = False
8095
# device ids already warned about an unmatched product id this run.
8196
self._warned_products = set()
97+
# gwIds an integration_discovery flow has already been raised for.
98+
self._discovered = set()
8299

83100
@callback
84101
def async_start(self) -> None:
@@ -89,7 +106,7 @@ def async_start(self) -> None:
89106
)
90107
if self._unsub_scan is None:
91108
self._unsub_scan = async_track_time_interval(
92-
self._hass, self._async_product_scan, SCAN_INTERVAL
109+
self._hass, self._async_discovery_scan, SCAN_INTERVAL
93110
)
94111

95112
@callback
@@ -158,45 +175,76 @@ async def _async_sweep(self, now=None) -> None:
158175
finally:
159176
self._scanning = False
160177

161-
async def _async_product_scan(self, now=None) -> None:
162-
"""Warn (once per device per run) about product ids the config lacks."""
178+
async def _async_discovery_scan(self, now=None) -> None:
179+
"""Full LAN scan: product-id check for known devices, discover new ones."""
163180
if self._scanning:
164181
return
165-
entries = [
166-
(entry, entry.data.get(CONF_DEVICE_ID), entry.data.get(CONF_TYPE))
167-
for entry in self._hass.config_entries.async_entries(DOMAIN)
168-
if entry.data.get(CONF_DEVICE_ID) and entry.data.get(CONF_TYPE)
169-
]
170-
if not entries:
171-
return
172-
173182
self._scanning = True
174183
try:
175-
for entry, device_id, config_type in entries:
176-
if device_id in self._warned_products:
177-
continue
178-
found = await self._hass.async_add_executor_job(_find_device, device_id)
179-
product_id = found.get("product_id") if found else None
180-
if not product_id:
181-
continue
182-
config = await self._hass.async_add_executor_job(
183-
get_config, config_type
184-
)
185-
if config is None or config.matches_product(product_id):
186-
continue
187-
# WARNING so it is visible under HA's default log level; once per
188-
# device per run to avoid noise.
189-
self._warned_products.add(device_id)
190-
_LOGGER.warning(
191-
"%s: device product id %s is not listed in its config (%s); "
192-
"please report it so support can be improved",
193-
entry.title,
194-
product_id,
195-
config_type,
196-
)
184+
found = await self._hass.async_add_executor_job(_scan_all)
185+
if not found:
186+
return
187+
188+
by_gwid = {}
189+
for info in found.values():
190+
gwid = info.get("gwId")
191+
if gwid:
192+
by_gwid[gwid] = info
193+
194+
configured = {}
195+
for entry in self._hass.config_entries.async_entries(DOMAIN):
196+
device_id = entry.data.get(CONF_DEVICE_ID)
197+
if device_id:
198+
configured[device_id] = entry
199+
200+
for gwid, info in by_gwid.items():
201+
entry = configured.get(gwid)
202+
if entry is not None:
203+
await self._check_product(entry, info.get("productKey"))
204+
else:
205+
self._discover_new(gwid, info)
197206
finally:
198207
self._scanning = False
199208

209+
async def _check_product(self, entry, product_id) -> None:
210+
"""Warn once per run when a configured device's product id is unlisted."""
211+
device_id = entry.data.get(CONF_DEVICE_ID)
212+
config_type = entry.data.get(CONF_TYPE)
213+
if not product_id or not config_type or device_id in self._warned_products:
214+
return
215+
config = await self._hass.async_add_executor_job(get_config, config_type)
216+
if config is None or config.matches_product(product_id):
217+
return
218+
# WARNING so it is visible under HA's default log level; once per device
219+
# per run to avoid noise.
220+
self._warned_products.add(device_id)
221+
_LOGGER.warning(
222+
"%s: device product id %s is not listed in its config (%s); "
223+
"please report it so support can be improved",
224+
entry.title,
225+
product_id,
226+
config_type,
227+
)
228+
229+
@callback
230+
def _discover_new(self, gwid, info) -> None:
231+
"""Raise an integration_discovery flow for a not-yet-configured device."""
232+
if gwid in self._discovered:
233+
return
234+
self._discovered.add(gwid)
235+
self._hass.async_create_task(
236+
self._hass.config_entries.flow.async_init(
237+
DOMAIN,
238+
context={"source": SOURCE_INTEGRATION_DISCOVERY},
239+
data={
240+
CONF_DEVICE_ID: gwid,
241+
CONF_HOST: info.get("ip"),
242+
"product_id": info.get("productKey"),
243+
"version": info.get("version"),
244+
},
245+
)
246+
)
247+
200248

201249
async def async_start_discovery(hass: HomeAssistant) -> None:
202250
"""Start the shared LAN discovery service if not already running."""

tests/test_config_flow.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,3 +1619,37 @@ async def test_flow_choose_entities_uses_cloud_name_as_default(
16191619
# Validate it accepts the cloud device name
16201620
validated = schema({CONF_NAME: "My Cloud Device"})
16211621
assert validated[CONF_NAME] == "My Cloud Device"
1622+
1623+
1624+
@pytest.mark.asyncio
1625+
async def test_flow_integration_discovery_shows_local_form(hass):
1626+
"""A device found by background discovery advances to the local setup form."""
1627+
result = await hass.config_entries.flow.async_init(
1628+
DOMAIN,
1629+
context={"source": "integration_discovery"},
1630+
data={
1631+
CONF_DEVICE_ID: "bfdiscovered000000",
1632+
CONF_HOST: "192.168.1.77",
1633+
"product_id": "keyxyz",
1634+
"version": "3.5",
1635+
},
1636+
)
1637+
assert result["type"] == "form"
1638+
assert result["step_id"] == "local"
1639+
1640+
1641+
@pytest.mark.asyncio
1642+
async def test_flow_integration_discovery_aborts_if_configured(hass):
1643+
"""A discovered device that is already configured (or ignored) aborts."""
1644+
entry = MockConfigEntry(domain=DOMAIN, unique_id="bfdiscovered000000")
1645+
entry.add_to_hass(hass)
1646+
result = await hass.config_entries.flow.async_init(
1647+
DOMAIN,
1648+
context={"source": "integration_discovery"},
1649+
data={
1650+
CONF_DEVICE_ID: "bfdiscovered000000",
1651+
CONF_HOST: "192.168.1.77",
1652+
},
1653+
)
1654+
assert result["type"] == "abort"
1655+
assert result["reason"] == "already_configured"

0 commit comments

Comments
 (0)