11"""
2- Active Tuya LAN discovery for configured devices .
2+ Active Tuya LAN discovery.
33
44When a router hands out new DHCP leases (e.g. after a reboot) a Tuya device
55can change IP. The integration then keeps trying the stale ``host`` stored in
99Tuya devices do not all announce themselves unprompted -- in particular
1010protocol 3.4/3.5 devices stay silent until they receive a discovery request
1111broadcast 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
2931References:
3032- tinytuya scanner discovery request (port 7000 for v3.5 devices):
3638from datetime import timedelta
3739
3840import tinytuya
41+ from homeassistant .config_entries import SOURCE_INTEGRATION_DISCOVERY
3942from homeassistant .const import CONF_HOST
4043from homeassistant .core import HomeAssistant , callback
4144from homeassistant .helpers .event import async_track_time_interval
5154# relocated on the first sweep after it drops.
5255SWEEP_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 .
5659SCAN_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+
7287class 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
201249async def async_start_discovery (hass : HomeAssistant ) -> None :
202250 """Start the shared LAN discovery service if not already running."""
0 commit comments