4
0

test_device.py 20 KB

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