test_device.py 15 KB

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