test_discovery.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. """Tests for the active Tuya LAN rediscovery sweeper."""
  2. import logging
  3. import pytest
  4. from homeassistant.const import CONF_HOST
  5. from pytest_homeassistant_custom_component.common import MockConfigEntry
  6. from custom_components.tuya_local.const import (
  7. CONF_DEVICE_ID,
  8. CONF_LOCAL_KEY,
  9. CONF_POLL_ONLY,
  10. CONF_PROTOCOL_VERSION,
  11. CONF_TYPE,
  12. DATA_DISCOVERY,
  13. DOMAIN,
  14. )
  15. from custom_components.tuya_local.helpers import discovery
  16. from custom_components.tuya_local.helpers.discovery import (
  17. TuyaLANRediscovery,
  18. async_start_discovery,
  19. async_stop_discovery,
  20. )
  21. TESTKEY = ")<jO<@)'P1|kR$Kd"
  22. DEVID = "bf1234567890abcdef"
  23. @pytest.fixture(autouse=True)
  24. def auto_enable_custom_integrations(enable_custom_integrations):
  25. yield
  26. def _make_entry(hass, host="192.168.1.10", options=None):
  27. entry = MockConfigEntry(
  28. domain=DOMAIN,
  29. version=13,
  30. minor_version=20,
  31. title="thermostat",
  32. data={
  33. CONF_DEVICE_ID: DEVID,
  34. CONF_HOST: host,
  35. CONF_LOCAL_KEY: TESTKEY,
  36. CONF_POLL_ONLY: False,
  37. CONF_PROTOCOL_VERSION: "auto",
  38. CONF_TYPE: "polytherm_polyalpha_thermostat",
  39. },
  40. options=options or {},
  41. )
  42. entry.add_to_hass(hass)
  43. return entry
  44. def _set_device(hass, returned_state, device_id=DEVID):
  45. """Register a fake device object in hass.data under the device id."""
  46. device = type("Dev", (), {"has_returned_state": returned_state})()
  47. hass.data.setdefault(DOMAIN, {})[device_id] = {"device": device}
  48. return device
  49. @pytest.mark.asyncio
  50. async def test_sweep_updates_unreachable_changed_host(hass, caplog, mocker):
  51. """An unreachable device gets relocated, its host updated, and it's logged at WARNING."""
  52. entry = _make_entry(hass, host="192.168.1.10")
  53. _set_device(hass, returned_state=False)
  54. mocker.patch(
  55. "custom_components.tuya_local.helpers.discovery._find_device",
  56. return_value={"ip": "192.168.1.55", "id": DEVID},
  57. )
  58. with caplog.at_level(
  59. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  60. ):
  61. await TuyaLANRediscovery(hass)._async_sweep()
  62. await hass.async_block_till_done()
  63. assert entry.data[CONF_HOST] == "192.168.1.55"
  64. # The IP change must be visible even when the entry runs at WARNING.
  65. assert "192.168.1.55" in caplog.text
  66. assert "192.168.1.10" in caplog.text
  67. @pytest.mark.asyncio
  68. async def test_sweep_skips_reachable_device(hass, mocker):
  69. """A device that is returning state is never scanned."""
  70. entry = _make_entry(hass, host="192.168.1.10")
  71. _set_device(hass, returned_state=True)
  72. find = mocker.patch(
  73. "custom_components.tuya_local.helpers.discovery._find_device",
  74. return_value={"ip": "192.168.1.55"},
  75. )
  76. await TuyaLANRediscovery(hass)._async_sweep()
  77. await hass.async_block_till_done()
  78. find.assert_not_called()
  79. assert entry.data[CONF_HOST] == "192.168.1.10"
  80. @pytest.mark.asyncio
  81. async def test_sweep_no_change_when_ip_same(hass, mocker):
  82. """If the scan returns the current IP, no entry update happens."""
  83. entry = _make_entry(hass, host="192.168.1.10")
  84. _set_device(hass, returned_state=False)
  85. mocker.patch(
  86. "custom_components.tuya_local.helpers.discovery._find_device",
  87. return_value={"ip": "192.168.1.10"},
  88. )
  89. update = mocker.spy(hass.config_entries, "async_update_entry")
  90. await TuyaLANRediscovery(hass)._async_sweep()
  91. await hass.async_block_till_done()
  92. update.assert_not_called()
  93. assert entry.data[CONF_HOST] == "192.168.1.10"
  94. @pytest.mark.asyncio
  95. async def test_sweep_handles_not_found(hass, mocker):
  96. """A scan that finds nothing must not raise or change anything."""
  97. entry = _make_entry(hass, host="192.168.1.10")
  98. _set_device(hass, returned_state=False)
  99. mocker.patch(
  100. "custom_components.tuya_local.helpers.discovery._find_device",
  101. return_value={"ip": None},
  102. )
  103. update = mocker.spy(hass.config_entries, "async_update_entry")
  104. await TuyaLANRediscovery(hass)._async_sweep()
  105. await hass.async_block_till_done()
  106. update.assert_not_called()
  107. assert entry.data[CONF_HOST] == "192.168.1.10"
  108. @pytest.mark.asyncio
  109. async def test_sweep_updates_host_stored_in_options(hass, mocker):
  110. """When the effective host lives in options, the update targets options."""
  111. entry = _make_entry(hass, host="10.0.0.1", options={CONF_HOST: "192.168.1.103"})
  112. _set_device(hass, returned_state=False)
  113. mocker.patch(
  114. "custom_components.tuya_local.helpers.discovery._find_device",
  115. return_value={"ip": "192.168.1.55"},
  116. )
  117. await TuyaLANRediscovery(hass)._async_sweep()
  118. await hass.async_block_till_done()
  119. assert entry.options[CONF_HOST] == "192.168.1.55"
  120. assert entry.data[CONF_HOST] == "192.168.1.55"
  121. @pytest.mark.asyncio
  122. async def test_sweep_scans_when_no_device_object(hass, mocker):
  123. """An entry with no device object yet (failed setup) is still scanned."""
  124. entry = _make_entry(hass, host="192.168.1.10")
  125. hass.data.setdefault(DOMAIN, {}) # no device bucket registered
  126. mocker.patch(
  127. "custom_components.tuya_local.helpers.discovery._find_device",
  128. return_value={"ip": "192.168.1.77"},
  129. )
  130. await TuyaLANRediscovery(hass)._async_sweep()
  131. await hass.async_block_till_done()
  132. assert entry.data[CONF_HOST] == "192.168.1.77"
  133. @pytest.mark.asyncio
  134. async def test_start_is_idempotent_and_stop_cancels(hass, mocker):
  135. """async_start_discovery schedules the sweep + scan intervals; stop cancels both."""
  136. unsub_sweep = mocker.MagicMock()
  137. unsub_scan = mocker.MagicMock()
  138. track = mocker.patch(
  139. "custom_components.tuya_local.helpers.discovery.async_track_time_interval",
  140. side_effect=[unsub_sweep, unsub_scan],
  141. )
  142. await async_start_discovery(hass)
  143. rediscovery = hass.data[DOMAIN][DATA_DISCOVERY]
  144. assert isinstance(rediscovery, TuyaLANRediscovery)
  145. assert track.call_count == 2
  146. # Second call must not schedule more intervals (singleton).
  147. await async_start_discovery(hass)
  148. assert track.call_count == 2
  149. async_stop_discovery(hass)
  150. unsub_sweep.assert_called_once()
  151. unsub_scan.assert_called_once()
  152. assert DATA_DISCOVERY not in hass.data[DOMAIN]
  153. def _fake_config(matches):
  154. """Minimal stand-in for a device config with a matches_product() method."""
  155. return type("Cfg", (), {"matches_product": lambda self, pid: matches})()
  156. @pytest.mark.asyncio
  157. async def test_product_scan_warns_once_on_unmatched_product(hass, caplog, mocker):
  158. """An unmatched product id is logged at WARNING, once per device per run."""
  159. _make_entry(hass, host="192.168.1.10")
  160. mocker.patch(
  161. "custom_components.tuya_local.helpers.discovery._find_device",
  162. return_value={"ip": "192.168.1.10", "product_id": "keyabc123"},
  163. )
  164. mocker.patch(
  165. "custom_components.tuya_local.helpers.discovery.get_config",
  166. return_value=_fake_config(False),
  167. )
  168. disc = TuyaLANRediscovery(hass)
  169. with caplog.at_level(
  170. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  171. ):
  172. await disc._async_product_scan()
  173. await hass.async_block_till_done()
  174. assert caplog.text.count("keyabc123") == 1
  175. # A second scan must not warn again for the same device.
  176. caplog.clear()
  177. await disc._async_product_scan()
  178. await hass.async_block_till_done()
  179. assert "keyabc123" not in caplog.text
  180. @pytest.mark.asyncio
  181. async def test_product_scan_silent_when_product_matches(hass, caplog, mocker):
  182. """No warning when the product id is listed in the config."""
  183. _make_entry(hass, host="192.168.1.10")
  184. mocker.patch(
  185. "custom_components.tuya_local.helpers.discovery._find_device",
  186. return_value={"ip": "192.168.1.10", "product_id": "keyabc123"},
  187. )
  188. mocker.patch(
  189. "custom_components.tuya_local.helpers.discovery.get_config",
  190. return_value=_fake_config(True),
  191. )
  192. with caplog.at_level(
  193. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  194. ):
  195. await TuyaLANRediscovery(hass)._async_product_scan()
  196. await hass.async_block_till_done()
  197. assert "is not listed" not in caplog.text
  198. @pytest.mark.asyncio
  199. async def test_product_scan_skips_when_no_product_id(hass, mocker):
  200. """If the scan returns no product id, the config is not even looked up."""
  201. _make_entry(hass, host="192.168.1.10")
  202. mocker.patch(
  203. "custom_components.tuya_local.helpers.discovery._find_device",
  204. return_value={"ip": "192.168.1.10"},
  205. )
  206. get_config = mocker.patch(
  207. "custom_components.tuya_local.helpers.discovery.get_config",
  208. )
  209. await TuyaLANRediscovery(hass)._async_product_scan()
  210. await hass.async_block_till_done()
  211. get_config.assert_not_called()
  212. @pytest.mark.asyncio
  213. async def test_product_scan_handles_missing_config(hass, caplog, mocker):
  214. """A missing config file must not warn or raise."""
  215. _make_entry(hass, host="192.168.1.10")
  216. mocker.patch(
  217. "custom_components.tuya_local.helpers.discovery._find_device",
  218. return_value={"ip": "192.168.1.10", "product_id": "keyabc123"},
  219. )
  220. mocker.patch(
  221. "custom_components.tuya_local.helpers.discovery.get_config",
  222. return_value=None,
  223. )
  224. with caplog.at_level(
  225. logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
  226. ):
  227. await TuyaLANRediscovery(hass)._async_product_scan()
  228. await hass.async_block_till_done()
  229. assert "keyabc123" not in caplog.text
  230. def test_module_exposes_expected_intervals():
  231. """Guard the cadences against accidental change."""
  232. assert discovery.SWEEP_INTERVAL.total_seconds() == 60
  233. assert discovery.SCAN_INTERVAL.total_seconds() == 600