test_discovery.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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 import discovery
  8. from custom_components.tuya_local.const import (
  9. CONF_DEVICE_ID,
  10. CONF_LOCAL_KEY,
  11. CONF_POLL_ONLY,
  12. CONF_PROTOCOL_VERSION,
  13. CONF_TYPE,
  14. DATA_DISCOVERY,
  15. DOMAIN,
  16. )
  17. from custom_components.tuya_local.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.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.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.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.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.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.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.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.discovery.async_track_time_interval",
  141. side_effect=[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. assert track.call_count == 1
  148. # Second call must not schedule more intervals (singleton).
  149. await async_start_discovery(hass)
  150. # assert track.call_count == 2
  151. assert track.call_count == 1
  152. async_stop_discovery(hass)
  153. # unsub_sweep.assert_called_once()
  154. unsub_scan.assert_called_once()
  155. assert DATA_DISCOVERY not in hass.data[DOMAIN]
  156. def _fake_config(matches):
  157. """Minimal stand-in for a device config with a matches_product() method."""
  158. return type("Cfg", (), {"matches_product": lambda self, pid: matches})()
  159. def _scan_result(gwid=DEVID, product="keyabc123", ip="192.168.1.10"):
  160. """A tinytuya.deviceScan-style result: keyed by IP, carrying gwId/productKey."""
  161. info = {"gwId": gwid, "ip": ip, "version": "3.5"}
  162. if product is not None:
  163. info["productKey"] = product
  164. return {ip: info}
  165. def _patch_flow_init(hass, mocker):
  166. """Patch the config-entries flow init with an awaitable mock."""
  167. return mocker.patch.object(
  168. hass.config_entries.flow, "async_init", new_callable=AsyncMock
  169. )
  170. @pytest.mark.asyncio
  171. async def test_product_scan_warns_once_on_unmatched_product(hass, caplog, mocker):
  172. """An unmatched product id is logged at WARNING, once per device per run."""
  173. _make_entry(hass, host="192.168.1.10")
  174. mocker.patch(
  175. "custom_components.tuya_local.discovery._scan_all",
  176. return_value=_scan_result(),
  177. )
  178. mocker.patch(
  179. "custom_components.tuya_local.discovery.get_config",
  180. return_value=_fake_config(False),
  181. )
  182. _patch_flow_init(hass, mocker)
  183. disc = TuyaLANRediscovery(hass)
  184. with caplog.at_level(
  185. logging.WARNING, logger="custom_components.tuya_local.discovery"
  186. ):
  187. await disc._async_discovery_scan()
  188. await hass.async_block_till_done()
  189. assert caplog.text.count("keyabc123") == 1
  190. # A second scan must not warn again for the same device.
  191. caplog.clear()
  192. await disc._async_discovery_scan()
  193. await hass.async_block_till_done()
  194. assert "keyabc123" not in caplog.text
  195. @pytest.mark.asyncio
  196. async def test_product_scan_silent_when_product_matches(hass, caplog, mocker):
  197. """No warning when the product id is listed in the config."""
  198. _make_entry(hass, host="192.168.1.10")
  199. mocker.patch(
  200. "custom_components.tuya_local.discovery._scan_all",
  201. return_value=_scan_result(),
  202. )
  203. mocker.patch(
  204. "custom_components.tuya_local.discovery.get_config",
  205. return_value=_fake_config(True),
  206. )
  207. _patch_flow_init(hass, mocker)
  208. with caplog.at_level(
  209. logging.WARNING, logger="custom_components.tuya_local.discovery"
  210. ):
  211. await TuyaLANRediscovery(hass)._async_discovery_scan()
  212. await hass.async_block_till_done()
  213. assert "is not listed" not in caplog.text
  214. @pytest.mark.asyncio
  215. async def test_product_scan_skips_when_no_product_id(hass, mocker):
  216. """If the scan reports no product id, the config is not even looked up."""
  217. _make_entry(hass, host="192.168.1.10")
  218. mocker.patch(
  219. "custom_components.tuya_local.discovery._scan_all",
  220. return_value=_scan_result(product=None),
  221. )
  222. get_config = mocker.patch(
  223. "custom_components.tuya_local.discovery.get_config",
  224. )
  225. _patch_flow_init(hass, mocker)
  226. await TuyaLANRediscovery(hass)._async_discovery_scan()
  227. await hass.async_block_till_done()
  228. get_config.assert_not_called()
  229. @pytest.mark.asyncio
  230. async def test_product_scan_handles_missing_config(hass, caplog, mocker):
  231. """A missing config file must not warn or raise."""
  232. _make_entry(hass, host="192.168.1.10")
  233. mocker.patch(
  234. "custom_components.tuya_local.discovery._scan_all",
  235. return_value=_scan_result(),
  236. )
  237. mocker.patch(
  238. "custom_components.tuya_local.discovery.get_config",
  239. return_value=None,
  240. )
  241. _patch_flow_init(hass, mocker)
  242. with caplog.at_level(
  243. logging.WARNING, logger="custom_components.tuya_local.discovery"
  244. ):
  245. await TuyaLANRediscovery(hass)._async_discovery_scan()
  246. await hass.async_block_till_done()
  247. assert "keyabc123" not in caplog.text
  248. @pytest.mark.asyncio
  249. async def test_discovery_raises_flow_for_unknown_device(hass, mocker):
  250. """An unconfigured device on the LAN starts an integration_discovery flow."""
  251. mocker.patch(
  252. "custom_components.tuya_local.discovery._scan_all",
  253. return_value=_scan_result(gwid="bfunknown000000000", ip="192.168.1.99"),
  254. )
  255. init = _patch_flow_init(hass, mocker)
  256. await TuyaLANRediscovery(hass)._async_discovery_scan()
  257. await hass.async_block_till_done()
  258. init.assert_awaited_once()
  259. args, kwargs = init.call_args
  260. assert args[0] == DOMAIN
  261. assert kwargs["context"]["source"] == "integration_discovery"
  262. assert kwargs["data"][CONF_DEVICE_ID] == "bfunknown000000000"
  263. assert kwargs["data"][CONF_HOST] == "192.168.1.99"
  264. @pytest.mark.asyncio
  265. async def test_discovery_skips_configured_device(hass, mocker):
  266. """A device already configured is not offered for discovery again."""
  267. _make_entry(hass, host="192.168.1.10") # DEVID is configured
  268. mocker.patch(
  269. "custom_components.tuya_local.discovery._scan_all",
  270. return_value=_scan_result(gwid=DEVID),
  271. )
  272. mocker.patch(
  273. "custom_components.tuya_local.discovery.get_config",
  274. return_value=_fake_config(True),
  275. )
  276. init = _patch_flow_init(hass, mocker)
  277. await TuyaLANRediscovery(hass)._async_discovery_scan()
  278. await hass.async_block_till_done()
  279. init.assert_not_awaited()
  280. @pytest.mark.asyncio
  281. async def test_discovery_raises_flow_only_once_per_device(hass, mocker):
  282. """Repeated scans do not spawn duplicate flows for the same new device."""
  283. mocker.patch(
  284. "custom_components.tuya_local.discovery._scan_all",
  285. return_value=_scan_result(gwid="bfunknown000000000", ip="192.168.1.99"),
  286. )
  287. init = _patch_flow_init(hass, mocker)
  288. disc = TuyaLANRediscovery(hass)
  289. await disc._async_discovery_scan()
  290. await hass.async_block_till_done()
  291. await disc._async_discovery_scan()
  292. await hass.async_block_till_done()
  293. assert init.await_count == 1
  294. @pytest.mark.asyncio
  295. async def test_discovery_scan_handles_empty_result(hass, mocker):
  296. """An empty scan (e.g. socket error) does nothing and does not raise."""
  297. _make_entry(hass, host="192.168.1.10")
  298. mocker.patch(
  299. "custom_components.tuya_local.discovery._scan_all",
  300. return_value={},
  301. )
  302. init = _patch_flow_init(hass, mocker)
  303. await TuyaLANRediscovery(hass)._async_discovery_scan()
  304. await hass.async_block_till_done()
  305. init.assert_not_awaited()
  306. def test_module_exposes_expected_intervals():
  307. """Guard the cadences against accidental change."""
  308. assert discovery.SWEEP_INTERVAL.total_seconds() == 60
  309. assert discovery.SCAN_INTERVAL.total_seconds() == 600