test_device.py 13 KB

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