test_device.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import tinytuya
  2. from datetime import datetime
  3. from time import sleep, time
  4. from unittest import IsolatedAsyncioTestCase
  5. from unittest.mock import AsyncMock, call, patch
  6. from homeassistant.const import TEMP_CELSIUS
  7. from custom_components.tuya_local.device import TuyaLocalDevice
  8. from .const import (
  9. EUROM_600_HEATER_PAYLOAD,
  10. )
  11. class TestDevice(IsolatedAsyncioTestCase):
  12. def setUp(self):
  13. device_patcher = patch("tinytuya.Device")
  14. self.addCleanup(device_patcher.stop)
  15. self.mock_api = device_patcher.start()
  16. hass_patcher = patch("homeassistant.core.HomeAssistant")
  17. self.addCleanup(hass_patcher.stop)
  18. self.hass = hass_patcher.start()
  19. self.subject = TuyaLocalDevice(
  20. "Some name", "some_dev_id", "some.ip.address", "some_local_key", self.hass()
  21. )
  22. def test_configures_tinytuya_correctly(self):
  23. self.mock_api.assert_called_once_with(
  24. "some_dev_id", "some.ip.address", "some_local_key"
  25. )
  26. self.assertIs(self.subject._api, self.mock_api())
  27. def test_name(self):
  28. """Returns the name given at instantiation."""
  29. self.assertEqual(self.subject.name, "Some name")
  30. def test_unique_id(self):
  31. """Returns the unique ID presented by the API class."""
  32. self.assertIs(self.subject.unique_id, self.mock_api().id)
  33. def test_device_info(self):
  34. """Returns generic info plus the unique ID for categorisation."""
  35. self.assertEqual(
  36. self.subject.device_info,
  37. {
  38. "identifiers": {("tuya_local", self.mock_api().id)},
  39. "name": "Some name",
  40. "manufacturer": "Tuya",
  41. },
  42. )
  43. def test_has_returned_state(self):
  44. """Returns True if the device has returned its state."""
  45. self.subject._cached_state = EUROM_600_HEATER_PAYLOAD
  46. self.assertTrue(self.subject.has_returned_state)
  47. self.subject._cached_state = {"updated_at": 0}
  48. self.assertFalse(self.subject.has_returned_state)
  49. def test_temperature_unit(self):
  50. self.assertEqual(self.subject.temperature_unit, TEMP_CELSIUS)
  51. async def test_refreshes_state_if_no_cached_state_exists(self):
  52. self.subject._cached_state = {}
  53. self.subject.async_refresh = AsyncMock()
  54. await self.subject.async_inferred_type()
  55. self.subject.async_refresh.assert_awaited()
  56. async def test_detection_returns_none_when_device_type_could_not_be_detected(self):
  57. self.subject._cached_state = {"2": False, "updated_at": datetime.now()}
  58. self.assertEqual(await self.subject.async_inferred_type(), None)
  59. async def test_does_not_refresh_more_often_than_cache_timeout(self):
  60. refresh_task = AsyncMock()
  61. self.subject._cached_state = {"updated_at": time() - 19}
  62. self.subject._refresh_task = awaitable = refresh_task()
  63. await self.subject.async_refresh()
  64. refresh_task.assert_awaited()
  65. self.assertIs(self.subject._refresh_task, awaitable)
  66. async def test_refreshes_when_there_is_no_pending_reset(self):
  67. async_job = AsyncMock()
  68. self.subject._cached_state = {"updated_at": time() - 19}
  69. self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
  70. await self.subject.async_refresh()
  71. self.subject._hass.async_add_executor_job.assert_called_once_with(
  72. self.subject.refresh
  73. )
  74. self.assertIs(self.subject._refresh_task, awaitable)
  75. async_job.assert_awaited()
  76. async def test_refreshes_when_there_is_expired_pending_reset(self):
  77. async_job = AsyncMock()
  78. self.subject._cached_state = {"updated_at": time() - 20}
  79. self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
  80. self.subject._refresh_task = {}
  81. await self.subject.async_refresh()
  82. self.subject._hass.async_add_executor_job.assert_called_once_with(
  83. self.subject.refresh
  84. )
  85. self.assertIs(self.subject._refresh_task, awaitable)
  86. async_job.assert_awaited()
  87. def test_refresh_reloads_status_from_device(self):
  88. self.subject._api.status.return_value = {"dps": {"1": False}}
  89. self.subject._cached_state = {"1": True}
  90. self.subject.refresh()
  91. self.subject._api.status.assert_called_once()
  92. self.assertEqual(self.subject._cached_state["1"], False)
  93. self.assertTrue(
  94. time() - 1 <= self.subject._cached_state["updated_at"] <= time()
  95. )
  96. def test_refresh_retries_up_to_four_times(self):
  97. self.subject._api.status.side_effect = [
  98. Exception("Error"),
  99. Exception("Error"),
  100. Exception("Error"),
  101. {"dps": {"1": False}},
  102. ]
  103. self.subject.refresh()
  104. self.assertEqual(self.subject._api.status.call_count, 4)
  105. self.assertEqual(self.subject._cached_state["1"], False)
  106. def test_refresh_clears_cached_state_and_pending_updates_after_failing_four_times(
  107. self,
  108. ):
  109. self.subject._cached_state = {"1": True}
  110. self.subject._pending_updates = {"1": False}
  111. self.subject._api.status.side_effect = [
  112. Exception("Error"),
  113. Exception("Error"),
  114. Exception("Error"),
  115. Exception("Error"),
  116. ]
  117. self.subject.refresh()
  118. self.assertEqual(self.subject._api.status.call_count, 4)
  119. self.assertEqual(self.subject._cached_state, {"updated_at": 0})
  120. self.assertEqual(self.subject._pending_updates, {})
  121. def test_api_protocol_version_is_rotated_with_each_failure(self):
  122. self.subject._api.set_version.assert_called_once_with(3.3)
  123. self.subject._api.set_version.reset_mock()
  124. self.subject._api.status.side_effect = [
  125. Exception("Error"),
  126. Exception("Error"),
  127. Exception("Error"),
  128. Exception("Error"),
  129. ]
  130. self.subject.refresh()
  131. self.subject._api.set_version.assert_has_calls(
  132. [call(3.1), call(3.3), call(3.1)]
  133. )
  134. def test_api_protocol_version_is_stable_once_successful(self):
  135. self.subject._api.set_version.assert_called_once_with(3.3)
  136. self.subject._api.set_version.reset_mock()
  137. self.subject._api.status.side_effect = [
  138. {"dps": {"1": False}},
  139. Exception("Error"),
  140. Exception("Error"),
  141. Exception("Error"),
  142. Exception("Error"),
  143. Exception("Error"),
  144. Exception("Error"),
  145. ]
  146. self.subject.refresh()
  147. self.subject.refresh()
  148. self.subject.refresh()
  149. self.subject._api.set_version.assert_has_calls([call(3.1), call(3.3)])
  150. def test_reset_cached_state_clears_cached_state_and_pending_updates(self):
  151. self.subject._cached_state = {"1": True, "updated_at": time()}
  152. self.subject._pending_updates = {"1": False}
  153. self.subject._reset_cached_state()
  154. self.assertEqual(self.subject._cached_state, {"updated_at": 0})
  155. self.assertEqual(self.subject._pending_updates, {})
  156. def test_get_property_returns_value_from_cached_state(self):
  157. self.subject._cached_state = {"1": True}
  158. self.assertEqual(self.subject.get_property("1"), True)
  159. def test_get_property_returns_pending_update_value(self):
  160. self.subject._pending_updates = {
  161. "1": {"value": False, "updated_at": time() - 9}
  162. }
  163. self.assertEqual(self.subject.get_property("1"), False)
  164. def test_pending_update_value_overrides_cached_value(self):
  165. self.subject._cached_state = {"1": True}
  166. self.subject._pending_updates = {
  167. "1": {"value": False, "updated_at": time() - 9}
  168. }
  169. self.assertEqual(self.subject.get_property("1"), False)
  170. def test_expired_pending_update_value_does_not_override_cached_value(self):
  171. self.subject._cached_state = {"1": True}
  172. self.subject._pending_updates = {
  173. "1": {"value": False, "updated_at": time() - 10}
  174. }
  175. self.assertEqual(self.subject.get_property("1"), True)
  176. def test_get_property_returns_none_when_value_does_not_exist(self):
  177. self.subject._cached_state = {"1": True}
  178. self.assertIs(self.subject.get_property("2"), None)
  179. async def test_async_set_property_schedules_job(self):
  180. async_job = AsyncMock()
  181. self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
  182. await self.subject.async_set_property("1", False)
  183. self.subject._hass.async_add_executor_job.assert_called_once_with(
  184. self.subject.set_property, "1", False
  185. )
  186. async_job.assert_awaited()
  187. def test_set_property_immediately_stores_new_value_to_pending_updates(self):
  188. self.subject.set_property("1", False)
  189. self.subject._cached_state = {"1": True}
  190. self.assertEqual(self.subject.get_property("1"), False)
  191. # wait for the debounce timer to avoid a teardown error
  192. sleep(2)
  193. def test_debounces_multiple_set_calls_into_one_api_call(self):
  194. with patch("custom_components.tuya_local.device.Timer") as mock:
  195. self.subject.set_property("1", True)
  196. mock.assert_called_once_with(0.001, self.subject._send_pending_updates)
  197. debounce = self.subject._debounce
  198. mock.reset_mock()
  199. self.subject.set_property("2", False)
  200. debounce.cancel.assert_called_once()
  201. mock.assert_called_once_with(1, self.subject._send_pending_updates)
  202. self.subject._api.generate_payload.return_value = "payload"
  203. self.subject._send_pending_updates()
  204. self.subject._api.generate_payload.assert_called_once_with(
  205. tinytuya.CONTROL, {"1": True, "2": False}
  206. )
  207. self.subject._api._send_receive.assert_called_once_with("payload")
  208. def test_set_properties_takes_no_action_when_no_properties_are_provided(self):
  209. with patch("custom_components.tuya_local.device.Timer") as mock:
  210. self.subject._set_properties({})
  211. mock.assert_not_called()
  212. def test_anticipate_property_value_updates_cached_state(self):
  213. self.subject._cached_state = {"1": True}
  214. self.subject.anticipate_property_value("1", False)
  215. self.assertEqual(self.subject._cached_state["1"], False)
  216. def test_get_key_for_value_returns_key_from_object_matching_value(self):
  217. obj = {"key1": "value1", "key2": "value2"}
  218. self.assertEqual(TuyaLocalDevice.get_key_for_value(obj, "value1"), "key1")
  219. self.assertEqual(TuyaLocalDevice.get_key_for_value(obj, "value2"), "key2")
  220. def test_get_key_for_value_returns_fallback_when_value_not_found(self):
  221. obj = {"key1": "value1", "key2": "value2"}
  222. self.assertEqual(
  223. TuyaLocalDevice.get_key_for_value(obj, "value3", fallback="fb"), "fb"
  224. )