test_discovery.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """Tests for the active Tuya LAN rediscovery sweeper."""
  2. import logging
  3. from unittest.mock import AsyncMock
  4. import pytest
  5. from homeassistant.const import CONF_HOST
  6. from pytest_homeassistant_custom_component.common import MockConfigEntry
  7. from custom_components.tuya_local.const import (
  8. CONF_DEVICE_ID,
  9. CONF_LOCAL_KEY,
  10. CONF_POLL_ONLY,
  11. CONF_PROTOCOL_VERSION,
  12. CONF_TYPE,
  13. DATA_DISCOVERY,
  14. DOMAIN,
  15. )
  16. from custom_components.tuya_local.helpers import discovery
  17. from custom_components.tuya_local.helpers.discovery import (
  18. TuyaLANRediscovery,
  19. async_start_discovery,
  20. async_stop_discovery,
  21. )
  22. TESTKEY = ")<jO<@)'P1|kR$Kd"
  23. DEVID = "bf1234567890abcdef"
  24. @pytest.fixture(autouse=True)
  25. def auto_enable_custom_integrations(enable_custom_integrations):
  26. yield
  27. def _make_entry(hass, host="192.168.1.10", options=None):
  28. entry = MockConfigEntry(
  29. domain=DOMAIN,
  30. version=13,
  31. minor_version=20,
  32. title="thermostat",
  33. data={
  34. CONF_DEVICE_ID: DEVID,
  35. CONF_HOST: host,
  36. CONF_LOCAL_KEY: TESTKEY,
  37. CONF_POLL_ONLY: False,
  38. CONF_PROTOCOL_VERSION: "auto",
  39. CONF_TYPE: "polytherm_polyalpha_thermostat",
  40. },
  41. options=options or {},
  42. )
  43. entry.add_to_hass(hass)
  44. return entry
  45. def _set_device(hass, returned_state, device_id=DEVID):
  46. """Register a fake device object in hass.data under the device id."""
  47. device = type("Dev", (), {"has_returned_state": returned_state})()
  48. hass.data.setdefault(DOMAIN, {})[device_id] = {"device": device}
  49. return device
  50. @pytest.mark.asyncio
  51. async def test_sweep_updates_unreachable_changed_host(hass, caplog, mocker):
  52. """An unreachable device gets relocated, its host updated, and it's logged at WARNING."""
  53. entry = _make_entry(hass, host="192.168.1.10")
  54. _set_device(hass, returned_state=False)
  55. mocker.patch(
  56. "custom_components.tuya_local.helpers.discovery._find_device",
  57. return_value={"ip": "192.168.1.55", "id": DEVID},
  58. )
  59. with caplog.at_level(
  60. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  61. ):
  62. await TuyaLANRediscovery(hass)._async_sweep()
  63. await hass.async_block_till_done()
  64. assert entry.data[CONF_HOST] == "192.168.1.55"
  65. # The IP change must be visible even when the entry runs at WARNING.
  66. assert "192.168.1.55" in caplog.text
  67. assert "192.168.1.10" in caplog.text
  68. @pytest.mark.asyncio
  69. async def test_sweep_skips_reachable_device(hass, mocker):
  70. """A device that is returning state is never scanned."""
  71. entry = _make_entry(hass, host="192.168.1.10")
  72. _set_device(hass, returned_state=True)
  73. find = mocker.patch(
  74. "custom_components.tuya_local.helpers.discovery._find_device",
  75. return_value={"ip": "192.168.1.55"},
  76. )
  77. await TuyaLANRediscovery(hass)._async_sweep()
  78. await hass.async_block_till_done()
  79. find.assert_not_called()
  80. assert entry.data[CONF_HOST] == "192.168.1.10"
  81. @pytest.mark.asyncio
  82. async def test_sweep_no_change_when_ip_same(hass, mocker):
  83. """If the scan returns the current IP, no entry update happens."""
  84. entry = _make_entry(hass, host="192.168.1.10")
  85. _set_device(hass, returned_state=False)
  86. mocker.patch(
  87. "custom_components.tuya_local.helpers.discovery._find_device",
  88. return_value={"ip": "192.168.1.10"},
  89. )
  90. update = mocker.spy(hass.config_entries, "async_update_entry")
  91. await TuyaLANRediscovery(hass)._async_sweep()
  92. await hass.async_block_till_done()
  93. update.assert_not_called()
  94. assert entry.data[CONF_HOST] == "192.168.1.10"
  95. @pytest.mark.asyncio
  96. async def test_sweep_handles_not_found(hass, mocker):
  97. """A scan that finds nothing must not raise or change anything."""
  98. entry = _make_entry(hass, host="192.168.1.10")
  99. _set_device(hass, returned_state=False)
  100. mocker.patch(
  101. "custom_components.tuya_local.helpers.discovery._find_device",
  102. return_value={"ip": None},
  103. )
  104. update = mocker.spy(hass.config_entries, "async_update_entry")
  105. await TuyaLANRediscovery(hass)._async_sweep()
  106. await hass.async_block_till_done()
  107. update.assert_not_called()
  108. assert entry.data[CONF_HOST] == "192.168.1.10"
  109. @pytest.mark.asyncio
  110. async def test_sweep_updates_host_stored_in_options(hass, mocker):
  111. """When the effective host lives in options, the update targets options."""
  112. entry = _make_entry(hass, host="10.0.0.1", options={CONF_HOST: "192.168.1.103"})
  113. _set_device(hass, returned_state=False)
  114. mocker.patch(
  115. "custom_components.tuya_local.helpers.discovery._find_device",
  116. return_value={"ip": "192.168.1.55"},
  117. )
  118. await TuyaLANRediscovery(hass)._async_sweep()
  119. await hass.async_block_till_done()
  120. assert entry.options[CONF_HOST] == "192.168.1.55"
  121. assert entry.data[CONF_HOST] == "192.168.1.55"
  122. @pytest.mark.asyncio
  123. async def test_sweep_scans_when_no_device_object(hass, mocker):
  124. """An entry with no device object yet (failed setup) is still scanned."""
  125. entry = _make_entry(hass, host="192.168.1.10")
  126. hass.data.setdefault(DOMAIN, {}) # no device bucket registered
  127. mocker.patch(
  128. "custom_components.tuya_local.helpers.discovery._find_device",
  129. return_value={"ip": "192.168.1.77"},
  130. )
  131. await TuyaLANRediscovery(hass)._async_sweep()
  132. await hass.async_block_till_done()
  133. assert entry.data[CONF_HOST] == "192.168.1.77"
  134. @pytest.mark.asyncio
  135. async def test_start_is_idempotent_and_stop_cancels(hass, mocker):
  136. """async_start_discovery schedules the sweep + scan intervals; stop cancels both."""
  137. unsub_sweep = mocker.MagicMock()
  138. unsub_scan = mocker.MagicMock()
  139. track = mocker.patch(
  140. "custom_components.tuya_local.helpers.discovery.async_track_time_interval",
  141. side_effect=[unsub_sweep, unsub_scan],
  142. )
  143. await async_start_discovery(hass)
  144. rediscovery = hass.data[DOMAIN][DATA_DISCOVERY]
  145. assert isinstance(rediscovery, TuyaLANRediscovery)
  146. assert track.call_count == 2
  147. # Second call must not schedule more intervals (singleton).
  148. await async_start_discovery(hass)
  149. assert track.call_count == 2
  150. async_stop_discovery(hass)
  151. unsub_sweep.assert_called_once()
  152. unsub_scan.assert_called_once()
  153. assert DATA_DISCOVERY not in hass.data[DOMAIN]
  154. def _fake_config(matches):
  155. """Minimal stand-in for a device config with a matches_product() method."""
  156. return type("Cfg", (), {"matches_product": lambda self, pid: matches})()
  157. def _scan_result(gwid=DEVID, product="keyabc123", ip="192.168.1.10"):
  158. """A tinytuya.deviceScan-style result: keyed by IP, carrying gwId/productKey."""
  159. info = {"gwId": gwid, "ip": ip, "version": "3.5"}
  160. if product is not None:
  161. info["productKey"] = product
  162. return {ip: info}
  163. def _patch_flow_init(hass, mocker):
  164. """Patch the config-entries flow init with an awaitable mock."""
  165. return mocker.patch.object(
  166. hass.config_entries.flow, "async_init", new_callable=AsyncMock
  167. )
  168. @pytest.mark.asyncio
  169. async def test_product_scan_warns_once_on_unmatched_product(hass, caplog, mocker):
  170. """An unmatched product id is logged at WARNING, once per device per run."""
  171. _make_entry(hass, host="192.168.1.10")
  172. mocker.patch(
  173. "custom_components.tuya_local.helpers.discovery._scan_all",
  174. return_value=_scan_result(),
  175. )
  176. mocker.patch(
  177. "custom_components.tuya_local.helpers.discovery.get_config",
  178. return_value=_fake_config(False),
  179. )
  180. _patch_flow_init(hass, mocker)
  181. disc = TuyaLANRediscovery(hass)
  182. with caplog.at_level(
  183. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  184. ):
  185. await disc._async_discovery_scan()
  186. await hass.async_block_till_done()
  187. assert caplog.text.count("keyabc123") == 1
  188. # A second scan must not warn again for the same device.
  189. caplog.clear()
  190. await disc._async_discovery_scan()
  191. await hass.async_block_till_done()
  192. assert "keyabc123" not in caplog.text
  193. @pytest.mark.asyncio
  194. async def test_product_scan_silent_when_product_matches(hass, caplog, mocker):
  195. """No warning when the product id is listed in the config."""
  196. _make_entry(hass, host="192.168.1.10")
  197. mocker.patch(
  198. "custom_components.tuya_local.helpers.discovery._scan_all",
  199. return_value=_scan_result(),
  200. )
  201. mocker.patch(
  202. "custom_components.tuya_local.helpers.discovery.get_config",
  203. return_value=_fake_config(True),
  204. )
  205. _patch_flow_init(hass, mocker)
  206. with caplog.at_level(
  207. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  208. ):
  209. await TuyaLANRediscovery(hass)._async_discovery_scan()
  210. await hass.async_block_till_done()
  211. assert "is not listed" not in caplog.text
  212. @pytest.mark.asyncio
  213. async def test_product_scan_skips_when_no_product_id(hass, mocker):
  214. """If the scan reports no product id, the config is not even looked up."""
  215. _make_entry(hass, host="192.168.1.10")
  216. mocker.patch(
  217. "custom_components.tuya_local.helpers.discovery._scan_all",
  218. return_value=_scan_result(product=None),
  219. )
  220. get_config = mocker.patch(
  221. "custom_components.tuya_local.helpers.discovery.get_config",
  222. )
  223. _patch_flow_init(hass, mocker)
  224. await TuyaLANRediscovery(hass)._async_discovery_scan()
  225. await hass.async_block_till_done()
  226. get_config.assert_not_called()
  227. @pytest.mark.asyncio
  228. async def test_product_scan_handles_missing_config(hass, caplog, mocker):
  229. """A missing config file must not warn or raise."""
  230. _make_entry(hass, host="192.168.1.10")
  231. mocker.patch(
  232. "custom_components.tuya_local.helpers.discovery._scan_all",
  233. return_value=_scan_result(),
  234. )
  235. mocker.patch(
  236. "custom_components.tuya_local.helpers.discovery.get_config",
  237. return_value=None,
  238. )
  239. _patch_flow_init(hass, mocker)
  240. with caplog.at_level(
  241. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  242. ):
  243. await TuyaLANRediscovery(hass)._async_discovery_scan()
  244. await hass.async_block_till_done()
  245. assert "keyabc123" not in caplog.text
  246. @pytest.mark.asyncio
  247. async def test_discovery_raises_flow_for_unknown_device(hass, mocker):
  248. """An unconfigured device on the LAN starts an integration_discovery flow."""
  249. mocker.patch(
  250. "custom_components.tuya_local.helpers.discovery._scan_all",
  251. return_value=_scan_result(gwid="bfunknown000000000", ip="192.168.1.99"),
  252. )
  253. init = _patch_flow_init(hass, mocker)
  254. await TuyaLANRediscovery(hass)._async_discovery_scan()
  255. await hass.async_block_till_done()
  256. init.assert_awaited_once()
  257. args, kwargs = init.call_args
  258. assert args[0] == DOMAIN
  259. assert kwargs["context"]["source"] == "integration_discovery"
  260. assert kwargs["data"][CONF_DEVICE_ID] == "bfunknown000000000"
  261. assert kwargs["data"][CONF_HOST] == "192.168.1.99"
  262. @pytest.mark.asyncio
  263. async def test_discovery_skips_configured_device(hass, mocker):
  264. """A device already configured is not offered for discovery again."""
  265. _make_entry(hass, host="192.168.1.10") # DEVID is configured
  266. mocker.patch(
  267. "custom_components.tuya_local.helpers.discovery._scan_all",
  268. return_value=_scan_result(gwid=DEVID),
  269. )
  270. mocker.patch(
  271. "custom_components.tuya_local.helpers.discovery.get_config",
  272. return_value=_fake_config(True),
  273. )
  274. init = _patch_flow_init(hass, mocker)
  275. await TuyaLANRediscovery(hass)._async_discovery_scan()
  276. await hass.async_block_till_done()
  277. init.assert_not_awaited()
  278. @pytest.mark.asyncio
  279. async def test_discovery_raises_flow_only_once_per_device(hass, mocker):
  280. """Repeated scans do not spawn duplicate flows for the same new device."""
  281. mocker.patch(
  282. "custom_components.tuya_local.helpers.discovery._scan_all",
  283. return_value=_scan_result(gwid="bfunknown000000000", ip="192.168.1.99"),
  284. )
  285. init = _patch_flow_init(hass, mocker)
  286. disc = TuyaLANRediscovery(hass)
  287. await disc._async_discovery_scan()
  288. await hass.async_block_till_done()
  289. await disc._async_discovery_scan()
  290. await hass.async_block_till_done()
  291. assert init.await_count == 1
  292. @pytest.mark.asyncio
  293. async def test_discovery_scan_handles_empty_result(hass, mocker):
  294. """An empty scan (e.g. socket error) does nothing and does not raise."""
  295. _make_entry(hass, host="192.168.1.10")
  296. mocker.patch(
  297. "custom_components.tuya_local.helpers.discovery._scan_all",
  298. return_value={},
  299. )
  300. init = _patch_flow_init(hass, mocker)
  301. await TuyaLANRediscovery(hass)._async_discovery_scan()
  302. await hass.async_block_till_done()
  303. init.assert_not_awaited()
  304. def test_module_exposes_expected_intervals():
  305. """Guard the cadences against accidental change."""
  306. assert discovery.SWEEP_INTERVAL.total_seconds() == 60
  307. assert discovery.SCAN_INTERVAL.total_seconds() == 600