discovery.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """
  2. Active Tuya LAN discovery.
  3. When a router hands out new DHCP leases (e.g. after a reboot) a Tuya device
  4. can change IP. The integration then keeps trying the stale ``host`` stored in
  5. the config entry, the device goes unavailable, and the user has to reconfigure
  6. it by hand.
  7. Tuya devices do not all announce themselves unprompted -- in particular
  8. protocol 3.4/3.5 devices stay silent until they receive a discovery request
  9. broadcast to UDP port 7000, at which point they reply with their id (``gwId``),
  10. current IP and ``productKey``. ``tinytuya``'s scanner sends exactly that request,
  11. so ``tinytuya.find_device``/``tinytuya.deviceScan`` locate devices regardless of
  12. how their IP changed. This is the same mechanism the config flow already uses via
  13. ``scan_for_device`` and the one ``localtuya`` uses to find devices in seconds.
  14. This module runs two active-scan tasks:
  15. - a fast sweep (every ``SWEEP_INTERVAL``) that relocates *unreachable* configured
  16. devices: it looks up the current IP by device id and updates the config entry's
  17. host in place. The existing update-listener reload then reconnects the device on
  18. the new IP -- no manual reconfiguration, no cloud round-trip, history preserved.
  19. Reachable devices are never scanned, so there is no traffic while healthy.
  20. - a slower full scan (every ``SCAN_INTERVAL``) that, from a single
  21. ``deviceScan``: (a) warns, once per device per HA start, when a *configured*
  22. device reports a ``productKey`` its config file does not list under
  23. ``products`` (so the config can be improved); and (b) raises an
  24. ``integration_discovery`` flow for each *unconfigured* device found, so it
  25. surfaces in Home Assistant for one-click setup (with the built-in ignore).
  26. References:
  27. - tinytuya scanner discovery request (port 7000 for v3.5 devices):
  28. https://github.com/jasonacox/tinytuya/blob/master/tinytuya/scanner.py
  29. - the integration's own config-flow scan: config_flow.scan_for_device
  30. """
  31. import logging
  32. from datetime import timedelta
  33. import tinytuya
  34. from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
  35. from homeassistant.const import CONF_HOST
  36. from homeassistant.core import HomeAssistant, callback
  37. from homeassistant.helpers.event import async_track_time_interval
  38. from .const import CONF_DEVICE_CID, CONF_DEVICE_ID, CONF_TYPE, DATA_DISCOVERY, DOMAIN
  39. from .helpers.config import get_device_id
  40. from .helpers.device_config import get_config
  41. _LOGGER = logging.getLogger(__name__)
  42. # How often to look for unreachable devices. Reachable devices are skipped, so
  43. # a healthy system generates no scan traffic; an unreachable device is normally
  44. # relocated on the first sweep after it drops.
  45. SWEEP_INTERVAL = timedelta(seconds=60)
  46. # How often to run the full network scan (product-id check + new-device
  47. # discovery). Infrequent, since neither action is time critical.
  48. SCAN_INTERVAL = timedelta(minutes=10)
  49. def _find_device(device_id):
  50. """Locate a device by id on the LAN (blocking; run in executor).
  51. Sends the Tuya discovery request and returns the scanner result dict
  52. (``{'ip': ..., 'id': ..., 'product_id': ..., ...}``), or a blank result on
  53. any socket error.
  54. """
  55. try:
  56. return tinytuya.find_device(dev_id=device_id)
  57. except OSError:
  58. return {"ip": None}
  59. def _scan_all():
  60. """Scan the LAN for all Tuya devices (blocking; run in executor).
  61. Returns tinytuya's dict keyed by IP, each value carrying ``gwId``,
  62. ``productKey`` and ``version``; an empty dict on any socket error.
  63. """
  64. try:
  65. return tinytuya.deviceScan(verbose=False, poll=False)
  66. except OSError:
  67. return {}
  68. class TuyaLANRediscovery:
  69. """Active LAN discovery for Tuya devices."""
  70. def __init__(self, hass: HomeAssistant) -> None:
  71. self._hass = hass
  72. self._unsub_sweep = None
  73. self._unsub_scan = None
  74. self._scanning = False
  75. # device ids already warned about an unmatched product id this run.
  76. self._warned_products = set()
  77. # gwIds an integration_discovery flow has already been raised for.
  78. self._discovered = set()
  79. @callback
  80. def async_start(self) -> None:
  81. """Begin periodic discovery tasks."""
  82. # TEMPORARILY DISABLED: the sweep is a bit too aggressive and can cause
  83. # incorrect IP updates if a device is temporarily unreachable, see #5713.
  84. #
  85. # if self._unsub_sweep is None:
  86. # self._unsub_sweep = async_track_time_interval(
  87. # self._hass, self._async_sweep, SWEEP_INTERVAL
  88. # )
  89. if self._unsub_scan is None:
  90. self._unsub_scan = async_track_time_interval(
  91. self._hass, self._async_discovery_scan, SCAN_INTERVAL
  92. )
  93. @callback
  94. def async_stop(self, event=None) -> None:
  95. """Stop periodic discovery tasks."""
  96. for attr in ("_unsub_sweep", "_unsub_scan"):
  97. unsub = getattr(self, attr)
  98. if unsub is not None:
  99. unsub()
  100. setattr(self, attr, None)
  101. def _unreachable_entries(self):
  102. """Yield (entry, device_id) for configured devices not returning state."""
  103. domain_data = self._hass.data.get(DOMAIN, {})
  104. for entry in self._hass.config_entries.async_entries(DOMAIN):
  105. device_id = entry.data.get(CONF_DEVICE_ID)
  106. if not device_id:
  107. continue
  108. bucket = domain_data.get(get_device_id(entry.data))
  109. device = bucket.get("device") if bucket else None
  110. # No device object yet (setup not complete / failed) or it has not
  111. # returned state recently -> treat as unreachable and worth a scan.
  112. if device is not None and device.has_returned_state:
  113. continue
  114. yield entry, device_id
  115. async def _async_sweep(self, now=None) -> None:
  116. """Scan for any unreachable devices and update changed hosts."""
  117. if self._scanning:
  118. return
  119. targets = list(self._unreachable_entries())
  120. if not targets:
  121. return
  122. self._scanning = True
  123. try:
  124. for entry, device_id in targets:
  125. found = await self._hass.async_add_executor_job(_find_device, device_id)
  126. ip = found.get("ip") if found else None
  127. if not ip:
  128. continue
  129. current = {**entry.data, **entry.options}.get(CONF_HOST)
  130. if ip == current:
  131. continue
  132. # WARNING, not INFO: an IP change is a notable operational event
  133. # the user may want to see, and config entries commonly run at
  134. # log level WARNING (which would suppress INFO).
  135. _LOGGER.warning(
  136. "%s: LAN IP changed to %s (was %s); updating configuration",
  137. entry.title,
  138. ip,
  139. current,
  140. )
  141. # Write the new host wherever it currently takes effect: always
  142. # to data, and also to options when options carries the host
  143. # (the options flow stores it there, overriding data), so the
  144. # merged config actually changes and the entry reloads.
  145. new_options = entry.options
  146. if CONF_HOST in entry.options:
  147. new_options = {**entry.options, CONF_HOST: ip}
  148. self._hass.config_entries.async_update_entry(
  149. entry,
  150. data={**entry.data, CONF_HOST: ip},
  151. options=new_options,
  152. )
  153. finally:
  154. self._scanning = False
  155. async def _async_discovery_scan(self, now=None) -> None:
  156. """Full LAN scan: product-id check for known devices, discover new ones."""
  157. if self._scanning:
  158. return
  159. self._scanning = True
  160. try:
  161. found = await self._hass.async_add_executor_job(_scan_all)
  162. if not found:
  163. return
  164. by_gwid = {}
  165. for info in found.values():
  166. gwid = info.get("gwId")
  167. if gwid:
  168. by_gwid[gwid] = info
  169. configured = {}
  170. for entry in self._hass.config_entries.async_entries(DOMAIN):
  171. device_id = entry.data.get(CONF_DEVICE_ID)
  172. if device_id:
  173. configured[device_id] = entry
  174. for gwid, info in by_gwid.items():
  175. entry = configured.get(gwid)
  176. if entry is not None:
  177. # Skip sub-devices for now, the WiFi reported product id is for the hub
  178. if not entry.data.get(CONF_DEVICE_CID):
  179. await self._check_product(entry, info.get("productKey"))
  180. else:
  181. self._discover_new(gwid, info)
  182. finally:
  183. self._scanning = False
  184. async def _check_product(self, entry, product_id) -> None:
  185. """Warn once per run when a configured device's product id is unlisted."""
  186. device_id = entry.data.get(CONF_DEVICE_ID)
  187. config_type = entry.data.get(CONF_TYPE)
  188. if not product_id or not config_type or device_id in self._warned_products:
  189. return
  190. config = await self._hass.async_add_executor_job(get_config, config_type)
  191. if config is None or config.matches_product(product_id):
  192. return
  193. # WARNING so it is visible under HA's default log level; once per device
  194. # per run to avoid noise.
  195. self._warned_products.add(device_id)
  196. _LOGGER.warning(
  197. "%s: device product id %s is not listed in its config (%s); "
  198. "if your device is an exact match for the config please report it so support can be improved",
  199. entry.title,
  200. product_id,
  201. config_type,
  202. )
  203. @callback
  204. def _discover_new(self, gwid, info) -> None:
  205. """Raise an integration_discovery flow for a not-yet-configured device."""
  206. if gwid in self._discovered:
  207. return
  208. self._discovered.add(gwid)
  209. self._hass.async_create_task(
  210. self._hass.config_entries.flow.async_init(
  211. DOMAIN,
  212. context={"source": SOURCE_INTEGRATION_DISCOVERY},
  213. data={
  214. CONF_DEVICE_ID: gwid,
  215. CONF_HOST: info.get("ip"),
  216. "product_id": info.get("productKey"),
  217. "version": info.get("version"),
  218. },
  219. )
  220. )
  221. async def async_start_discovery(hass: HomeAssistant) -> None:
  222. """Start the shared LAN discovery service if not already running."""
  223. domain_data = hass.data.setdefault(DOMAIN, {})
  224. if domain_data.get(DATA_DISCOVERY) is not None:
  225. return
  226. rediscovery = TuyaLANRediscovery(hass)
  227. domain_data[DATA_DISCOVERY] = rediscovery
  228. rediscovery.async_start()
  229. @callback
  230. def async_stop_discovery(hass: HomeAssistant) -> None:
  231. """Stop the shared LAN discovery service if running."""
  232. domain_data = hass.data.get(DOMAIN, {})
  233. rediscovery = domain_data.pop(DATA_DISCOVERY, None)
  234. if rediscovery is not None:
  235. rediscovery.async_stop()