test_device.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import threading
  2. import tinytuya
  3. from datetime import datetime, timedelta
  4. from time import sleep, time
  5. from unittest import IsolatedAsyncioTestCase
  6. from unittest.mock import AsyncMock, call, patch
  7. from homeassistant.const import TEMP_CELSIUS
  8. from custom_components.tuya_local.const import (
  9. CONF_TYPE_DEHUMIDIFIER,
  10. CONF_TYPE_FAN,
  11. CONF_TYPE_GECO_HEATER,
  12. CONF_TYPE_EUROM_600_HEATER,
  13. CONF_TYPE_GPCV_HEATER,
  14. CONF_TYPE_GPPH_HEATER,
  15. CONF_TYPE_GSH_HEATER,
  16. CONF_TYPE_KOGAN_HEATER,
  17. CONF_TYPE_KOGAN_SWITCH,
  18. CONF_TYPE_GARDENPAC_HEATPUMP,
  19. CONF_TYPE_PURLINE_M100_HEATER,
  20. )
  21. from custom_components.tuya_local.device import TuyaLocalDevice
  22. from .const import (
  23. DEHUMIDIFIER_PAYLOAD,
  24. FAN_PAYLOAD,
  25. GECO_HEATER_PAYLOAD,
  26. EUROM_600_HEATER_PAYLOAD,
  27. GPCV_HEATER_PAYLOAD,
  28. GPPH_HEATER_PAYLOAD,
  29. GSH_HEATER_PAYLOAD,
  30. KOGAN_HEATER_PAYLOAD,
  31. KOGAN_SOCKET_PAYLOAD,
  32. GARDENPAC_HEATPUMP_PAYLOAD,
  33. PURLINE_M100_HEATER_PAYLOAD,
  34. )
  35. class TestDevice(IsolatedAsyncioTestCase):
  36. def setUp(self):
  37. device_patcher = patch("tinytuya.Device")
  38. self.addCleanup(device_patcher.stop)
  39. self.mock_api = device_patcher.start()
  40. hass_patcher = patch("homeassistant.core.HomeAssistant")
  41. self.addCleanup(hass_patcher.stop)
  42. self.hass = hass_patcher.start()
  43. self.subject = TuyaLocalDevice(
  44. "Some name", "some_dev_id", "some.ip.address", "some_local_key", self.hass()
  45. )
  46. def test_configures_tinytuya_correctly(self):
  47. self.mock_api.assert_called_once_with(
  48. "some_dev_id", "some.ip.address", "some_local_key"
  49. )
  50. self.assertIs(self.subject._api, self.mock_api())
  51. def test_name(self):
  52. """Returns the name given at instantiation."""
  53. self.assertEqual(self.subject.name, "Some name")
  54. def test_unique_id(self):
  55. """Returns the unique ID presented by the API class."""
  56. self.assertIs(self.subject.unique_id, self.mock_api().id)
  57. def test_device_info(self):
  58. """Returns generic info plus the unique ID for categorisation."""
  59. self.assertEqual(
  60. self.subject.device_info,
  61. {
  62. "identifiers": {("tuya_local", self.mock_api().id)},
  63. "name": "Some name",
  64. "manufacturer": "Tuya",
  65. },
  66. )
  67. def test_temperature_unit(self):
  68. self.assertEqual(self.subject.temperature_unit, TEMP_CELSIUS)
  69. async def test_refreshes_state_if_no_cached_state_exists(self):
  70. self.subject._cached_state = {}
  71. self.subject.async_refresh = AsyncMock()
  72. await self.subject.async_inferred_type()
  73. self.subject.async_refresh.assert_awaited()
  74. async def test_detects_eurom_600_heater_payload(self):
  75. self.subject._cached_state = EUROM_600_HEATER_PAYLOAD
  76. self.assertEqual(
  77. await self.subject.async_inferred_type(), CONF_TYPE_EUROM_600_HEATER
  78. )
  79. async def test_detects_geco_heater_payload(self):
  80. self.subject._cached_state = GECO_HEATER_PAYLOAD
  81. self.assertEqual(
  82. await self.subject.async_inferred_type(), CONF_TYPE_GECO_HEATER
  83. )
  84. async def test_detects_gpcv_heater_payload(self):
  85. self.subject._cached_state = GPCV_HEATER_PAYLOAD
  86. self.assertEqual(
  87. await self.subject.async_inferred_type(), CONF_TYPE_GPCV_HEATER
  88. )
  89. async def test_detects_gpph_heater_payload(self):
  90. self.subject._cached_state = GPPH_HEATER_PAYLOAD
  91. self.assertEqual(
  92. await self.subject.async_inferred_type(), CONF_TYPE_GPPH_HEATER
  93. )
  94. async def test_detects_dehumidifier_payload(self):
  95. self.subject._cached_state = DEHUMIDIFIER_PAYLOAD
  96. self.assertEqual(
  97. await self.subject.async_inferred_type(), CONF_TYPE_DEHUMIDIFIER
  98. )
  99. async def test_detects_fan_payload(self):
  100. self.subject._cached_state = FAN_PAYLOAD
  101. self.assertEqual(await self.subject.async_inferred_type(), CONF_TYPE_FAN)
  102. async def test_detects_kogan_heater_payload(self):
  103. self.subject._cached_state = KOGAN_HEATER_PAYLOAD
  104. self.assertEqual(
  105. await self.subject.async_inferred_type(), CONF_TYPE_KOGAN_HEATER
  106. )
  107. async def test_detects_kogan_socket_payload(self):
  108. self.subject._cached_state = KOGAN_SOCKET_PAYLOAD
  109. self.assertEqual(
  110. await self.subject.async_inferred_type(), CONF_TYPE_KOGAN_SWITCH
  111. )
  112. async def test_detects_gsh_heater_payload(self):
  113. self.subject._cached_state = GSH_HEATER_PAYLOAD
  114. self.assertEqual(await self.subject.async_inferred_type(), CONF_TYPE_GSH_HEATER)
  115. async def test_detects_gardenpac_heatpump_payload(self):
  116. self.subject._cached_state = GARDENPAC_HEATPUMP_PAYLOAD
  117. self.assertEqual(
  118. await self.subject.async_inferred_type(), CONF_TYPE_GARDENPAC_HEATPUMP
  119. )
  120. async def test_detects_purline_m100_heater_payload(self):
  121. self.subject._cached_state = PURLINE_M100_HEATER_PAYLOAD
  122. self.assertEqual(
  123. await self.subject.async_inferred_type(), CONF_TYPE_PURLINE_M100_HEATER
  124. )
  125. async def test_detection_returns_none_when_device_type_could_not_be_detected(self):
  126. self.subject._cached_state = {"1": False}
  127. self.assertEqual(await self.subject.async_inferred_type(), None)
  128. async def test_does_not_refresh_more_often_than_cache_timeout(self):
  129. refresh_task = AsyncMock()
  130. self.subject._cached_state = {"updated_at": time() - 19}
  131. self.subject._refresh_task = awaitable = refresh_task()
  132. await self.subject.async_refresh()
  133. refresh_task.assert_awaited()
  134. self.assertIs(self.subject._refresh_task, awaitable)
  135. async def test_refreshes_when_there_is_no_pending_reset(self):
  136. async_job = AsyncMock()
  137. self.subject._cached_state = {"updated_at": time() - 19}
  138. self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
  139. await self.subject.async_refresh()
  140. self.subject._hass.async_add_executor_job.assert_called_once_with(
  141. self.subject.refresh
  142. )
  143. self.assertIs(self.subject._refresh_task, awaitable)
  144. async_job.assert_awaited()
  145. async def test_refreshes_when_there_is_expired_pending_reset(self):
  146. async_job = AsyncMock()
  147. self.subject._cached_state = {"updated_at": time() - 20}
  148. self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
  149. self.subject._refresh_task = {}
  150. await self.subject.async_refresh()
  151. self.subject._hass.async_add_executor_job.assert_called_once_with(
  152. self.subject.refresh
  153. )
  154. self.assertIs(self.subject._refresh_task, awaitable)
  155. async_job.assert_awaited()
  156. def test_refresh_reloads_status_from_device(self):
  157. self.subject._api.status.return_value = {"dps": {"1": False}}
  158. self.subject._cached_state = {"1": True}
  159. self.subject.refresh()
  160. self.subject._api.status.assert_called_once()
  161. self.assertEqual(self.subject._cached_state["1"], False)
  162. self.assertTrue(
  163. time() - 1 <= self.subject._cached_state["updated_at"] <= time()
  164. )
  165. def test_refresh_retries_up_to_four_times(self):
  166. self.subject._api.status.side_effect = [
  167. Exception("Error"),
  168. Exception("Error"),
  169. Exception("Error"),
  170. {"dps": {"1": False}},
  171. ]
  172. self.subject.refresh()
  173. self.assertEqual(self.subject._api.status.call_count, 4)
  174. self.assertEqual(self.subject._cached_state["1"], False)
  175. def test_refresh_clears_cached_state_and_pending_updates_after_failing_four_times(
  176. self,
  177. ):
  178. self.subject._cached_state = {"1": True}
  179. self.subject._pending_updates = {"1": False}
  180. self.subject._api.status.side_effect = [
  181. Exception("Error"),
  182. Exception("Error"),
  183. Exception("Error"),
  184. Exception("Error"),
  185. ]
  186. self.subject.refresh()
  187. self.assertEqual(self.subject._api.status.call_count, 4)
  188. self.assertEqual(self.subject._cached_state, {"updated_at": 0})
  189. self.assertEqual(self.subject._pending_updates, {})
  190. def test_api_protocol_version_is_rotated_with_each_failure(self):
  191. self.subject._api.set_version.assert_called_once_with(3.3)
  192. self.subject._api.set_version.reset_mock()
  193. self.subject._api.status.side_effect = [
  194. Exception("Error"),
  195. Exception("Error"),
  196. Exception("Error"),
  197. Exception("Error"),
  198. ]
  199. self.subject.refresh()
  200. self.subject._api.set_version.assert_has_calls(
  201. [call(3.1), call(3.3), call(3.1)]
  202. )
  203. def test_api_protocol_version_is_stable_once_successful(self):
  204. self.subject._api.set_version.assert_called_once_with(3.3)
  205. self.subject._api.set_version.reset_mock()
  206. self.subject._api.status.side_effect = [
  207. {"dps": {"1": False}},
  208. Exception("Error"),
  209. Exception("Error"),
  210. Exception("Error"),
  211. Exception("Error"),
  212. Exception("Error"),
  213. Exception("Error"),
  214. ]
  215. self.subject.refresh()
  216. self.subject.refresh()
  217. self.subject.refresh()
  218. self.subject._api.set_version.assert_has_calls([call(3.1), call(3.3)])
  219. def test_reset_cached_state_clears_cached_state_and_pending_updates(self):
  220. self.subject._cached_state = {"1": True, "updated_at": time()}
  221. self.subject._pending_updates = {"1": False}
  222. self.subject._reset_cached_state()
  223. self.assertEqual(self.subject._cached_state, {"updated_at": 0})
  224. self.assertEqual(self.subject._pending_updates, {})
  225. def test_get_property_returns_value_from_cached_state(self):
  226. self.subject._cached_state = {"1": True}
  227. self.assertEqual(self.subject.get_property("1"), True)
  228. def test_get_property_returns_pending_update_value(self):
  229. self.subject._pending_updates = {
  230. "1": {"value": False, "updated_at": time() - 9}
  231. }
  232. self.assertEqual(self.subject.get_property("1"), False)
  233. def test_pending_update_value_overrides_cached_value(self):
  234. self.subject._cached_state = {"1": True}
  235. self.subject._pending_updates = {
  236. "1": {"value": False, "updated_at": time() - 9}
  237. }
  238. self.assertEqual(self.subject.get_property("1"), False)
  239. def test_expired_pending_update_value_does_not_override_cached_value(self):
  240. self.subject._cached_state = {"1": True}
  241. self.subject._pending_updates = {
  242. "1": {"value": False, "updated_at": time() - 10}
  243. }
  244. self.assertEqual(self.subject.get_property("1"), True)
  245. def test_get_property_returns_none_when_value_does_not_exist(self):
  246. self.subject._cached_state = {"1": True}
  247. self.assertIs(self.subject.get_property("2"), None)
  248. async def test_async_set_property_schedules_job(self):
  249. async_job = AsyncMock()
  250. self.subject._hass.async_add_executor_job.return_value = awaitable = async_job()
  251. await self.subject.async_set_property("1", False)
  252. self.subject._hass.async_add_executor_job.assert_called_once_with(
  253. self.subject.set_property, "1", False
  254. )
  255. async_job.assert_awaited()
  256. def test_set_property_immediately_stores_new_value_to_pending_updates(self):
  257. self.subject.set_property("1", False)
  258. self.subject._cached_state = {"1": True}
  259. self.assertEqual(self.subject.get_property("1"), False)
  260. # wait for the debounce timer to avoid a teardown error
  261. sleep(2)
  262. def test_debounces_multiple_set_calls_into_one_api_call(self):
  263. with patch("custom_components.tuya_local.device.Timer") as mock:
  264. self.subject.set_property("1", True)
  265. mock.assert_called_once_with(1, self.subject._send_pending_updates)
  266. debounce = self.subject._debounce
  267. mock.reset_mock()
  268. self.subject.set_property("2", False)
  269. debounce.cancel.assert_called_once()
  270. mock.assert_called_once_with(1, self.subject._send_pending_updates)
  271. self.subject._api.generate_payload.return_value = "payload"
  272. self.subject._send_pending_updates()
  273. self.subject._api.generate_payload.assert_called_once_with(
  274. tinytuya.CONTROL, {"1": True, "2": False}
  275. )
  276. self.subject._api._send_receive.assert_called_once_with("payload")
  277. def test_set_properties_takes_no_action_when_no_properties_are_provided(self):
  278. with patch("custom_components.tuya_local.device.Timer") as mock:
  279. self.subject._set_properties({})
  280. mock.assert_not_called()
  281. def test_anticipate_property_value_updates_cached_state(self):
  282. self.subject._cached_state = {"1": True}
  283. self.subject.anticipate_property_value("1", False)
  284. self.assertEqual(self.subject._cached_state["1"], False)
  285. def test_get_key_for_value_returns_key_from_object_matching_value(self):
  286. obj = {"key1": "value1", "key2": "value2"}
  287. self.assertEqual(TuyaLocalDevice.get_key_for_value(obj, "value1"), "key1")
  288. self.assertEqual(TuyaLocalDevice.get_key_for_value(obj, "value2"), "key2")
  289. def test_get_key_for_value_returns_fallback_when_value_not_found(self):
  290. obj = {"key1": "value1", "key2": "value2"}
  291. self.assertEqual(
  292. TuyaLocalDevice.get_key_for_value(obj, "value3", fallback="fb"), "fb"
  293. )