discovery.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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 .config import get_device_id
  40. from .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. if self._unsub_sweep is None:
  83. self._unsub_sweep = async_track_time_interval(
  84. self._hass, self._async_sweep, SWEEP_INTERVAL
  85. )
  86. if self._unsub_scan is None:
  87. self._unsub_scan = async_track_time_interval(
  88. self._hass, self._async_discovery_scan, SCAN_INTERVAL
  89. )
  90. @callback
  91. def async_stop(self, event=None) -> None:
  92. """Stop periodic discovery tasks."""
  93. for attr in ("_unsub_sweep", "_unsub_scan"):
  94. unsub = getattr(self, attr)
  95. if unsub is not None:
  96. unsub()
  97. setattr(self, attr, None)
  98. def _unreachable_entries(self):
  99. """Yield (entry, device_id) for configured devices not returning state."""
  100. domain_data = self._hass.data.get(DOMAIN, {})
  101. for entry in self._hass.config_entries.async_entries(DOMAIN):
  102. device_id = entry.data.get(CONF_DEVICE_ID)
  103. if not device_id:
  104. continue
  105. bucket = domain_data.get(get_device_id(entry.data))
  106. device = bucket.get("device") if bucket else None
  107. # No device object yet (setup not complete / failed) or it has not
  108. # returned state recently -> treat as unreachable and worth a scan.
  109. if device is not None and device.has_returned_state:
  110. continue
  111. yield entry, device_id
  112. async def _async_sweep(self, now=None) -> None:
  113. """Scan for any unreachable devices and update changed hosts."""
  114. if self._scanning:
  115. return
  116. targets = list(self._unreachable_entries())
  117. if not targets:
  118. return
  119. self._scanning = True
  120. try:
  121. for entry, device_id in targets:
  122. found = await self._hass.async_add_executor_job(_find_device, device_id)
  123. ip = found.get("ip") if found else None
  124. if not ip:
  125. continue
  126. current = {**entry.data, **entry.options}.get(CONF_HOST)
  127. if ip == current:
  128. continue
  129. # WARNING, not INFO: an IP change is a notable operational event
  130. # the user may want to see, and config entries commonly run at
  131. # log level WARNING (which would suppress INFO).
  132. _LOGGER.warning(
  133. "%s: LAN IP changed to %s (was %s); updating configuration",
  134. entry.title,
  135. ip,
  136. current,
  137. )
  138. # Write the new host wherever it currently takes effect: always
  139. # to data, and also to options when options carries the host
  140. # (the options flow stores it there, overriding data), so the
  141. # merged config actually changes and the entry reloads.
  142. new_options = entry.options
  143. if CONF_HOST in entry.options:
  144. new_options = {**entry.options, CONF_HOST: ip}
  145. self._hass.config_entries.async_update_entry(
  146. entry,
  147. data={**entry.data, CONF_HOST: ip},
  148. options=new_options,
  149. )
  150. finally:
  151. self._scanning = False
  152. async def _async_discovery_scan(self, now=None) -> None:
  153. """Full LAN scan: product-id check for known devices, discover new ones."""
  154. if self._scanning:
  155. return
  156. self._scanning = True
  157. try:
  158. found = await self._hass.async_add_executor_job(_scan_all)
  159. if not found:
  160. return
  161. by_gwid = {}
  162. for info in found.values():
  163. gwid = info.get("gwId")
  164. if gwid:
  165. by_gwid[gwid] = info
  166. configured = {}
  167. for entry in self._hass.config_entries.async_entries(DOMAIN):
  168. device_id = entry.data.get(CONF_DEVICE_ID)
  169. if device_id:
  170. configured[device_id] = entry
  171. for gwid, info in by_gwid.items():
  172. entry = configured.get(gwid)
  173. if entry is not None:
  174. # Skip sub-devices for now, the WiFi reported product id is for the hub
  175. if not entry.data.get(CONF_DEVICE_CID):
  176. await self._check_product(entry, info.get("productKey"))
  177. else:
  178. self._discover_new(gwid, info)
  179. finally:
  180. self._scanning = False
  181. async def _check_product(self, entry, product_id) -> None:
  182. """Warn once per run when a configured device's product id is unlisted."""
  183. device_id = entry.data.get(CONF_DEVICE_ID)
  184. config_type = entry.data.get(CONF_TYPE)
  185. if not product_id or not config_type or device_id in self._warned_products:
  186. return
  187. config = await self._hass.async_add_executor_job(get_config, config_type)
  188. if config is None or config.matches_product(product_id):
  189. return
  190. # WARNING so it is visible under HA's default log level; once per device
  191. # per run to avoid noise.
  192. self._warned_products.add(device_id)
  193. _LOGGER.warning(
  194. "%s: device product id %s is not listed in its config (%s); "
  195. "if your device is an exact match for the config please report it so support can be improved",
  196. entry.title,
  197. product_id,
  198. config_type,
  199. )
  200. @callback
  201. def _discover_new(self, gwid, info) -> None:
  202. """Raise an integration_discovery flow for a not-yet-configured device."""
  203. if gwid in self._discovered:
  204. return
  205. self._discovered.add(gwid)
  206. self._hass.async_create_task(
  207. self._hass.config_entries.flow.async_init(
  208. DOMAIN,
  209. context={"source": SOURCE_INTEGRATION_DISCOVERY},
  210. data={
  211. CONF_DEVICE_ID: gwid,
  212. CONF_HOST: info.get("ip"),
  213. "product_id": info.get("productKey"),
  214. "version": info.get("version"),
  215. },
  216. )
  217. )
  218. async def async_start_discovery(hass: HomeAssistant) -> None:
  219. """Start the shared LAN discovery service if not already running."""
  220. domain_data = hass.data.setdefault(DOMAIN, {})
  221. if domain_data.get(DATA_DISCOVERY) is not None:
  222. return
  223. rediscovery = TuyaLANRediscovery(hass)
  224. domain_data[DATA_DISCOVERY] = rediscovery
  225. rediscovery.async_start()
  226. @callback
  227. def async_stop_discovery(hass: HomeAssistant) -> None:
  228. """Stop the shared LAN discovery service if running."""
  229. domain_data = hass.data.get(DOMAIN, {})
  230. rediscovery = domain_data.pop(DATA_DISCOVERY, None)
  231. if rediscovery is not None:
  232. rediscovery.async_stop()