test_discovery.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  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 one interval; stop cancels it."""
  136. unsub = mocker.MagicMock()
  137. track = mocker.patch(
  138. "custom_components.tuya_local.helpers.discovery.async_track_time_interval",
  139. return_value=unsub,
  140. )
  141. await async_start_discovery(hass)
  142. rediscovery = hass.data[DOMAIN][DATA_DISCOVERY]
  143. assert isinstance(rediscovery, TuyaLANRediscovery)
  144. assert track.call_count == 1
  145. # Second call must not schedule another interval (singleton).
  146. await async_start_discovery(hass)
  147. assert track.call_count == 1
  148. async_stop_discovery(hass)
  149. unsub.assert_called_once()
  150. assert DATA_DISCOVERY not in hass.data[DOMAIN]
  151. def test_module_exposes_expected_interval():
  152. """Guard the sweep cadence against accidental change."""
  153. assert discovery.SWEEP_INTERVAL.total_seconds() == 60