test_device.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. import asyncio
  2. from time import time
  3. import pytest
  4. # from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
  5. from custom_components.tuya_local.device import TuyaLocalDevice
  6. from .const import EUROM_600_HEATER_PAYLOAD
  7. @pytest.fixture
  8. def mock_api(mocker):
  9. mock = mocker.patch("tinytuya.Device")
  10. mock.parent = None
  11. yield mock
  12. @pytest.fixture
  13. def patched_hass(hass, mocker):
  14. hass.is_running = True
  15. hass.is_stopping = False
  16. hass.data = {"tuya_local": {}}
  17. async def job(func, *args):
  18. print(f"{args}")
  19. return func(*args)
  20. mocker.patch.object(hass, "async_add_executor_job", side_effect=job)
  21. mocker.patch.object(hass, "async_create_task")
  22. return hass
  23. @pytest.fixture
  24. def subject(patched_hass, mock_api, mocker):
  25. subject = TuyaLocalDevice(
  26. "Some name",
  27. "some_dev_id",
  28. "some.ip.address",
  29. "some_local_key",
  30. "auto",
  31. None,
  32. patched_hass,
  33. )
  34. # For most tests we want the protocol working
  35. subject._api_protocol_version_index = 0
  36. subject._api_protocol_working = True
  37. subject._protocol_configured = "auto"
  38. return subject
  39. def test_name(subject):
  40. """Returns the name given at instantiation."""
  41. assert subject.name == "Some name"
  42. def test_unique_id(subject, mock_api):
  43. """Returns the unique ID presented by the API class."""
  44. assert subject.unique_id is mock_api().id
  45. def test_device_info(subject, mock_api):
  46. """Returns generic info plus the unique ID for categorisation."""
  47. assert subject.device_info == {
  48. "identifiers": {("tuya_local", mock_api().id)},
  49. "name": "Some name",
  50. "manufacturer": "Tuya",
  51. }
  52. def test_has_returned_state(subject):
  53. """Returns True if the device has returned its state."""
  54. subject._cached_state = EUROM_600_HEATER_PAYLOAD
  55. assert subject.has_returned_state
  56. subject._cached_state = {"updated_at": 0}
  57. assert not subject.has_returned_state
  58. @pytest.mark.asyncio
  59. async def test_refreshes_state_if_no_cached_state_exists(subject, mocker):
  60. subject._cached_state = {}
  61. subject.async_refresh = mocker.AsyncMock()
  62. await subject.async_inferred_type()
  63. subject.async_refresh.assert_awaited()
  64. @pytest.mark.asyncio
  65. async def test_detection_returns_none_when_device_type_not_detected(subject):
  66. subject._cached_state = {"192": False, "updated_at": time()}
  67. assert await subject.async_inferred_type() is None
  68. @pytest.mark.asyncio
  69. async def test_refresh_retries_up_to_eleven_times(subject, mock_api):
  70. subject._api_protocol_working = False
  71. mock_api().status.side_effect = [
  72. Exception("Error"),
  73. Exception("Error"),
  74. Exception("Error"),
  75. Exception("Error"),
  76. Exception("Error"),
  77. Exception("Error"),
  78. Exception("Error"),
  79. Exception("Error"),
  80. Exception("Error"),
  81. Exception("Error"),
  82. {"dps": {"1": False}},
  83. ]
  84. await subject.async_refresh()
  85. assert mock_api().status.call_count == 11
  86. assert subject._cached_state["1"] is False
  87. @pytest.mark.asyncio
  88. async def test_refresh_clears_cache_after_allowed_failures(subject, mock_api):
  89. subject._cached_state = {"1": True}
  90. subject._pending_updates = {
  91. "1": {"value": False, "updated_at": time(), "sent": True}
  92. }
  93. mock_api().status.side_effect = [
  94. Exception("Error"),
  95. Exception("Error"),
  96. Exception("Error"),
  97. ]
  98. await subject.async_refresh()
  99. assert mock_api().status.call_count == 3
  100. assert subject._cached_state == {"updated_at": 0}
  101. assert subject._pending_updates == {}
  102. @pytest.mark.asyncio
  103. async def test_api_protocol_version_is_rotated_with_each_failure(
  104. subject, mock_api, mocker
  105. ):
  106. subject._api_protocol_version_index = None
  107. subject._api_protocol_working = False
  108. mock_api().status.side_effect = [
  109. Exception("Error"),
  110. Exception("Error"),
  111. Exception("Error"),
  112. Exception("Error"),
  113. Exception("Error"),
  114. Exception("Error"),
  115. Exception("Error"),
  116. ]
  117. await subject.async_refresh()
  118. mock_api().set_version.assert_has_calls(
  119. [
  120. mocker.call(3.1),
  121. mocker.call(3.2),
  122. mocker.call(3.4),
  123. mocker.call(3.5),
  124. mocker.call(3.3),
  125. mocker.call(3.3),
  126. mocker.call(3.1),
  127. ]
  128. )
  129. @pytest.mark.asyncio
  130. async def test_api_protocol_version_is_stable_once_successful(
  131. subject, mock_api, mocker
  132. ):
  133. subject._api_protocol_version_index = None
  134. subject._api_protocol_working = False
  135. mock_api().status.side_effect = [
  136. Exception("Error"),
  137. Exception("Error"),
  138. Exception("Error"),
  139. {"dps": {"1": False}},
  140. {"dps": {"1": False}},
  141. Exception("Error"),
  142. Exception("Error"),
  143. {"dps": {"1": False}},
  144. ]
  145. await subject.async_refresh()
  146. assert subject._api_protocol_version_index == 3
  147. assert subject._api_protocol_working
  148. await subject.async_refresh()
  149. assert subject._api_protocol_version_index == 3
  150. await subject.async_refresh()
  151. assert subject._api_protocol_version_index == 3
  152. mock_api().set_version.assert_has_calls(
  153. [
  154. mocker.call(3.1),
  155. mocker.call(3.2),
  156. mocker.call(3.4),
  157. ]
  158. )
  159. @pytest.mark.asyncio
  160. async def test_api_protocol_version_is_not_rotated_when_not_auto(subject, mock_api):
  161. # Set up preconditions for the test
  162. subject._protocol_configured = 3.4
  163. subject._api_protocol_version_index = None
  164. subject._api_protocol_working = False
  165. mock_api().status.side_effect = [
  166. Exception("Error"),
  167. Exception("Error"),
  168. Exception("Error"),
  169. {"dps": {"1": False}},
  170. {"dps": {"1": False}},
  171. Exception("Error"),
  172. Exception("Error"),
  173. Exception("Error"),
  174. Exception("Error"),
  175. Exception("Error"),
  176. Exception("Error"),
  177. Exception("Error"),
  178. {"dps": {"1": False}},
  179. ]
  180. await subject._rotate_api_protocol_version()
  181. mock_api().set_version.assert_called_once_with(3.4)
  182. mock_api().set_version.reset_mock()
  183. await subject.async_refresh()
  184. assert subject._api_protocol_version_index == 3
  185. await subject.async_refresh()
  186. assert subject._api_protocol_version_index == 3
  187. await subject.async_refresh()
  188. assert subject._api_protocol_version_index == 3
  189. def test_reset_cached_state_clears_cached_state_and_pending_updates(subject):
  190. subject._cached_state = {"1": True, "updated_at": time()}
  191. subject._pending_updates = {
  192. "1": {"value": False, "updated_at": time(), "sent": True}
  193. }
  194. subject._reset_cached_state()
  195. assert subject._cached_state == {"updated_at": 0}
  196. assert subject._pending_updates == {}
  197. def test_get_property_returns_value_from_cached_state(subject):
  198. subject._cached_state = {"1": True}
  199. assert subject.get_property("1") is True
  200. def test_get_property_returns_pending_update_value(subject):
  201. subject._pending_updates = {
  202. "1": {"value": False, "updated_at": time() - 4, "sent": True}
  203. }
  204. assert subject.get_property("1") is False
  205. def test_pending_update_value_overrides_cached_value(subject):
  206. subject._cached_state = {"1": True}
  207. subject._pending_updates = {
  208. "1": {"value": False, "updated_at": time() - 4, "sent": True}
  209. }
  210. assert subject.get_property("1") is False
  211. def test_expired_pending_update_value_does_not_override_cached_value(subject):
  212. subject._cached_state = {"1": True}
  213. subject._pending_updates = {
  214. "1": {"value": False, "updated_at": time() - 5, "sent": True}
  215. }
  216. assert subject.get_property("1") is True
  217. def test_get_property_returns_none_when_value_does_not_exist(subject):
  218. subject._cached_state = {"1": True}
  219. assert subject.get_property("2") is None
  220. @pytest.mark.asyncio
  221. async def test_async_set_property_sends_to_api(subject, mock_api):
  222. await subject.async_set_property("1", False)
  223. mock_api().set_multiple_values.assert_called_once()
  224. @pytest.mark.asyncio
  225. async def test_set_property_immediately_stores_pending_updates(subject):
  226. subject._cached_state = {"1": True}
  227. await subject.async_set_property("1", False)
  228. assert not subject.get_property("1")
  229. @pytest.mark.asyncio
  230. async def test_set_properties_takes_no_action_when_nothing_provided(subject, mocker):
  231. mock = mocker.patch("asyncio.sleep")
  232. await subject.async_set_properties({})
  233. mock.assert_not_called()
  234. def test_anticipate_property_value_updates_cached_state(subject):
  235. subject._cached_state = {"1": True}
  236. subject.anticipate_property_value("1", False)
  237. assert subject._cached_state["1"] is False
  238. def test_get_key_for_value_returns_key_from_object_matching_value(subject):
  239. obj = {"key1": "value1", "key2": "value2"}
  240. assert TuyaLocalDevice.get_key_for_value(obj, "value1") == "key1"
  241. assert TuyaLocalDevice.get_key_for_value(obj, "value2") == "key2"
  242. def test_get_key_for_value_returns_fallback_when_value_not_found(subject):
  243. obj = {"key1": "value1", "key2": "value2"}
  244. assert TuyaLocalDevice.get_key_for_value(obj, "value3", fallback="fb") == "fb"
  245. def test_refresh_cached_state(subject, mock_api):
  246. # set up preconditions
  247. mock_api().status.return_value = {"dps": {"1": "CHANGED"}}
  248. subject._cached_state = {"1": "UNCHANGED", "updated_at": 123}
  249. # call the function under test
  250. subject._refresh_cached_state()
  251. # Did it call the API as expected?
  252. mock_api().status.assert_called_once()
  253. # Did it update the cached state?
  254. assert subject._cached_state == {"1": "CHANGED"} | subject._cached_state
  255. # Did it update the timestamp on the cached state?
  256. assert subject._cached_state["updated_at"] == pytest.approx(time(), abs=2)
  257. def test_set_values(subject, mock_api):
  258. # set up preconditions
  259. subject._pending_updates = {
  260. "1": {"value": "sample", "updated_at": time() - 2, "sent": False},
  261. }
  262. # call the function under test
  263. subject._set_values({"1": "sample"})
  264. # did it send what it was asked?
  265. mock_api().set_multiple_values.assert_called_once_with({"1": "sample"}, nowait=True)
  266. # did it mark the pending updates as sent?
  267. assert subject._pending_updates["1"]["sent"]
  268. # did it update the time on the pending updates?
  269. assert subject._pending_updates["1"]["updated_at"] == pytest.approx(time(), abs=2)
  270. # did it lock and unlock when sending
  271. # subject._lock.acquire.assert_called_once()
  272. # subject._lock.release.assert_called_once()
  273. def test_pending_updates_cleared_on_receipt(subject):
  274. # Set up the preconditions
  275. now = time()
  276. subject._pending_updates = {
  277. "1": {"value": True, "updated_at": now, "sent": True},
  278. "2": {"value": True, "updated_at": now, "sent": False}, # unsent
  279. "3": {"value": True, "updated_at": now, "sent": True}, # unmatched
  280. "4": {"value": True, "updated_at": now, "sent": True}, # not received
  281. }
  282. subject._remove_properties_from_pending_updates({"1": True, "2": True, "3": False})
  283. assert subject._pending_updates == {
  284. "2": {"value": True, "updated_at": now, "sent": False},
  285. "3": {"value": True, "updated_at": now, "sent": True},
  286. "4": {"value": True, "updated_at": now, "sent": True},
  287. }
  288. def test_actually_start(subject, mocker, patched_hass):
  289. # Set up the preconditions
  290. mocker.patch.object(subject, "receive_loop", return_value="LOOP")
  291. mocker.patch.object(subject, "_refresh_task", new=mocker.AsyncMock)
  292. subject._running = False
  293. mocker.patch.object(patched_hass, "async_create_task")
  294. # patched_hass.async_create_task = mocker.MagicMock()
  295. # patched_hass.bus.async_listen_once = mocker.AsyncMock()
  296. # patched_hass.bus.async_listen_once.return_value = "LISTENER"
  297. # run the function under test
  298. subject.actually_start()
  299. # did it register a listener for EVENT_HOMEASSISTANT_STOP?
  300. # patched_hass.bus.async_listen_once.assert_called_once_with(
  301. # EVENT_HOMEASSISTANT_STOP, subject.async_stop
  302. # )
  303. # assert subject._shutdown_listener == "LISTENER"
  304. # did it set the running flag?
  305. assert subject._running
  306. # did it schedule the loop?
  307. # task.assert_called_once()
  308. def test_start_starts_when_ha_running(subject, patched_hass, mocker):
  309. # Set up preconditions
  310. patched_hass.is_running = True
  311. listener = mocker.MagicMock()
  312. subject._startup_listener = listener
  313. subject.actually_start = mocker.MagicMock()
  314. # Call the function under test
  315. subject.start()
  316. # Did it actually start?
  317. subject.actually_start.assert_called_once()
  318. # Did it cancel the startup listener?
  319. assert subject._startup_listener is None
  320. listener.assert_called_once()
  321. def test_start_schedules_for_later_when_ha_starting(subject, patched_hass, mocker):
  322. # Set up preconditions
  323. patched_hass.is_running = False
  324. subject.actually_start = mocker.MagicMock()
  325. # Call the function under test
  326. subject.start()
  327. # Did it avoid actually starting?
  328. subject.actually_start.assert_not_called()
  329. # Did it register a listener?
  330. # assert subject._startup_listener == "LISTENER"
  331. # patched_hass.bus.async_listen_once.assert_called_once_with(
  332. # EVENT_HOMEASSISTANT_STARTED, subject.actually_start
  333. # )
  334. def test_start_does_nothing_when_ha_stopping(subject, patched_hass, mocker):
  335. # Set up preconditions
  336. patched_hass.is_running = True
  337. patched_hass.is_stopping = True
  338. subject.actually_start = mocker.MagicMock()
  339. # Call the function under test
  340. subject.start()
  341. # Did it avoid actually starting?
  342. subject.actually_start.assert_not_called()
  343. # Did it avoid registering a listener?
  344. # patched_hass.bus.async_listen_once.assert_not_called()
  345. assert subject._startup_listener is None
  346. @pytest.mark.asyncio
  347. async def test_async_stop(subject, mocker):
  348. # Set up preconditions
  349. listener = mocker.MagicMock()
  350. subject._refresh_task = None
  351. subject._shutdown_listener = listener
  352. subject._children = [1, 2, 3]
  353. # Call the function under test
  354. await subject.async_stop()
  355. # Shutdown listener doesn't get cancelled as HA does that
  356. listener.assert_not_called()
  357. # Were the child entities cleared?
  358. assert subject._children == []
  359. # Did it wait for the refresh task to finish then clear it?
  360. # This doesn't work because AsyncMock only mocks awaitable method calls
  361. # but we want an awaitable object
  362. # refresh.assert_awaited_once()
  363. assert subject._refresh_task is None
  364. @pytest.mark.asyncio
  365. async def test_async_stop_when_not_running(subject):
  366. # Set up preconditions
  367. _refresh_task = None
  368. subject._shutdown_listener = None
  369. subject._children = []
  370. # Call the function under test
  371. await subject.async_stop()
  372. # Was the shutdown listener left empty?
  373. assert subject._shutdown_listener is None
  374. # Were the child entities cleared?
  375. assert subject._children == []
  376. # Was the refresh task left empty?
  377. assert subject._refresh_task is None
  378. def test_register_first_entity_ha_running(subject, mocker):
  379. # Set up preconditions
  380. subject._children = []
  381. subject._running = False
  382. subject._startup_listener = None
  383. subject.start = mocker.MagicMock()
  384. entity = mocker.AsyncMock()
  385. entity._config = mocker.MagicMock()
  386. entity._config.dps.return_value = []
  387. # despite the name, the below HA function is not async and does not need to be awaited
  388. entity.async_schedule_update_ha_state = mocker.MagicMock()
  389. # Call the function under test
  390. subject.register_entity(entity)
  391. # Was the entity added to the list?
  392. assert subject._children == [entity]
  393. # Did we start the loop?
  394. subject.start.assert_called_once()
  395. def test_register_subsequent_entity_ha_running(subject, mocker):
  396. # Set up preconditions
  397. first = mocker.AsyncMock()
  398. second = mocker.AsyncMock()
  399. second._config = mocker.MagicMock()
  400. second._config.dps.return_value = []
  401. subject._children = [first]
  402. subject._running = True
  403. subject._startup_listener = None
  404. subject.start = mocker.MagicMock()
  405. # Call the function under test
  406. subject.register_entity(second)
  407. # Was the entity added to the list?
  408. assert set(subject._children) == set([first, second])
  409. # Did we avoid restarting the loop?
  410. subject.start.assert_not_called()
  411. def test_register_subsequent_entity_ha_starting(subject, mocker):
  412. # Set up preconditions
  413. first = mocker.AsyncMock()
  414. second = mocker.AsyncMock()
  415. second._config = mocker.MagicMock()
  416. second._config.dps.return_value = []
  417. subject._children = [first]
  418. subject._running = False
  419. subject._startup_listener = mocker.MagicMock()
  420. subject.start = mocker.MagicMock()
  421. # Call the function under test
  422. subject.register_entity(second)
  423. # Was the entity added to the list?
  424. assert set(subject._children) == set([first, second])
  425. # Did we avoid restarting the loop?
  426. subject.start.assert_not_called()
  427. @pytest.mark.asyncio
  428. async def test_unregister_one_of_many_entities(subject, mocker):
  429. # Set up preconditions
  430. subject._children = ["First", "Second"]
  431. subject.async_stop = mocker.AsyncMock()
  432. # Call the function under test
  433. await subject.async_unregister_entity("First")
  434. # Was the entity removed from the list?
  435. assert set(subject._children) == set(["Second"])
  436. # Is the loop still running?
  437. subject.async_stop.assert_not_called()
  438. @pytest.mark.asyncio
  439. async def test_unregister_last_entity(subject, mocker):
  440. # Set up preconditions
  441. subject._children = ["Last"]
  442. subject.async_stop = mocker.AsyncMock()
  443. # Call the function under test
  444. await subject.async_unregister_entity("Last")
  445. # Was the entity removed from the list?
  446. assert subject._children == []
  447. # Was the loop stopped?
  448. subject.async_stop.assert_called_once()
  449. @pytest.mark.asyncio
  450. async def test_async_receive(subject, mock_api, mocker):
  451. # Set up preconditions
  452. mock_api().status.return_value = {"dps": {"1": "INIT", "2": 2}}
  453. mock_api().receive.return_value = {"1": "UPDATED"}
  454. subject._running = True
  455. subject._cached_state = {"updated_at": 0}
  456. # Call the function under test
  457. print("starting test loop...")
  458. loop = subject.async_receive()
  459. print("getting first iteration...")
  460. result = await loop.__anext__()
  461. # Check that the loop was started, but without persistent connection
  462. # since there was no state returned yet and it might need to negotiate
  463. # version.
  464. mock_api().set_socketPersistent.assert_called_once_with(False)
  465. # Check that a full poll was done
  466. mock_api().status.assert_called_once()
  467. assert result == {"1": "INIT", "2": 2, "full_poll": True}
  468. # Prepare for next round
  469. subject._cached_state = subject._cached_state | result
  470. mock_api().set_socketPersistent.reset_mock()
  471. mock_api().status.reset_mock()
  472. # Wait long enough to force a heartbeat poll on the next iteration
  473. await asyncio.sleep(subject._heartbeat_interval + 0.1)
  474. # Call the function under test
  475. print("getting second iteration...")
  476. result = await loop.__anext__()
  477. # Check that a heartbeat poll was done
  478. mock_api().status.assert_not_called()
  479. mock_api().heartbeat.assert_called_once()
  480. mock_api().receive.assert_called_once()
  481. assert result == {"1": "UPDATED", "full_poll": False}
  482. # Check that the connection was made persistent now that data has been
  483. # returned
  484. mock_api().set_socketPersistent.assert_called_once_with(True)
  485. # Prepare for next iteration
  486. subject._running = False
  487. mock_api().set_socketPersistent.reset_mock()
  488. # Call the function under test
  489. print("getting last iteration...")
  490. try:
  491. result = await loop.__anext__()
  492. pytest.fail("Should have raised an exception to quit the loop")
  493. # Check that the loop terminated
  494. except StopAsyncIteration:
  495. pass
  496. mock_api().set_socketPersistent.assert_called_once_with(False)
  497. def test_should_poll(subject):
  498. subject._cached_state = {"1": "sample", "updated_at": time()}
  499. subject._poll_only = False
  500. subject._temporary_poll = False
  501. # Test temporary poll via pause/resume
  502. assert not subject.should_poll
  503. subject.pause()
  504. assert subject.should_poll
  505. subject.resume()
  506. assert not subject.should_poll
  507. # Test configured polling
  508. subject._poll_only = True
  509. assert subject.should_poll
  510. subject._poll_only = False
  511. # Test initial polling
  512. subject._cached_state = {}
  513. assert subject.should_poll