test_remote.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. """Tests for the remote entity."""
  2. import asyncio
  3. import json
  4. from collections import defaultdict
  5. from unittest.mock import AsyncMock, MagicMock, Mock, patch
  6. import pytest
  7. from pytest_homeassistant_custom_component.common import MockConfigEntry
  8. from custom_components.tuya_local.const import (
  9. CONF_DEVICE_ID,
  10. CONF_PROTOCOL_VERSION,
  11. CONF_TYPE,
  12. DOMAIN,
  13. )
  14. from custom_components.tuya_local.remote import (
  15. CMD_SEND,
  16. CMD_SEND_RF,
  17. TuyaLocalRemote,
  18. async_setup_entry,
  19. )
  20. @pytest.mark.asyncio
  21. async def test_init_entry(hass):
  22. """Test the initialisation."""
  23. entry = MockConfigEntry(
  24. domain=DOMAIN,
  25. data={
  26. CONF_TYPE: "ir_remote_sensors",
  27. CONF_DEVICE_ID: "dummy",
  28. CONF_PROTOCOL_VERSION: "auto",
  29. },
  30. )
  31. # although async, the async_add_entities function passed to
  32. # async_setup_entry is called truly asynchronously. If we use
  33. # AsyncMock, it expects us to await the result.
  34. m_add_entities = Mock()
  35. m_device = AsyncMock()
  36. hass.data[DOMAIN] = {}
  37. hass.data[DOMAIN]["dummy"] = {}
  38. hass.data[DOMAIN]["dummy"]["device"] = m_device
  39. await async_setup_entry(hass, entry, m_add_entities)
  40. assert type(hass.data[DOMAIN]["dummy"]["remote"]) is TuyaLocalRemote
  41. m_add_entities.assert_called_once()
  42. @pytest.mark.asyncio
  43. async def test_init_entry_fails_if_device_has_no_remote(hass):
  44. """Test initialisation when device has no matching entity"""
  45. entry = MockConfigEntry(
  46. domain=DOMAIN,
  47. data={
  48. CONF_TYPE: "smartplugv1",
  49. CONF_DEVICE_ID: "dummy",
  50. CONF_PROTOCOL_VERSION: "auto",
  51. },
  52. )
  53. # although async, the async_add_entities function passed to
  54. # async_setup_entry is called truly asynchronously. If we use
  55. # AsyncMock, it expects us to await the result.
  56. m_add_entities = Mock()
  57. m_device = AsyncMock()
  58. hass.data[DOMAIN] = {}
  59. hass.data[DOMAIN]["dummy"] = {}
  60. hass.data[DOMAIN]["dummy"]["device"] = m_device
  61. try:
  62. await async_setup_entry(hass, entry, m_add_entities)
  63. assert False
  64. except ValueError:
  65. pass
  66. m_add_entities.assert_not_called()
  67. @pytest.mark.asyncio
  68. async def test_init_entry_fails_if_config_is_missing(hass):
  69. """Test initialisation when device has no matching entity"""
  70. entry = MockConfigEntry(
  71. domain=DOMAIN,
  72. data={
  73. CONF_TYPE: "non_existing",
  74. CONF_DEVICE_ID: "dummy",
  75. CONF_PROTOCOL_VERSION: "auto",
  76. },
  77. )
  78. # although async, the async_add_entities function passed to
  79. # async_setup_entry is called truly asynchronously. If we use
  80. # AsyncMock, it expects us to await the result.
  81. m_add_entities = Mock()
  82. m_device = AsyncMock()
  83. hass.data[DOMAIN] = {}
  84. hass.data[DOMAIN]["dummy"] = {}
  85. hass.data[DOMAIN]["dummy"]["device"] = m_device
  86. try:
  87. await async_setup_entry(hass, entry, m_add_entities)
  88. assert False
  89. except ValueError:
  90. pass
  91. m_add_entities.assert_not_called()
  92. def _make_remote(has_receive=True, has_control=False, has_delay=False, has_type=False):
  93. """Create a TuyaLocalRemote with mocked internals."""
  94. device = MagicMock()
  95. device._hass = MagicMock()
  96. device.unique_id = "test_remote_123"
  97. device.async_set_properties = AsyncMock()
  98. device.anticipate_property_value = MagicMock()
  99. remote = object.__new__(TuyaLocalRemote)
  100. remote._device = device
  101. remote._config = MagicMock()
  102. remote._config.name = "Test Remote"
  103. remote._config.translation_key = None
  104. remote._config.translation_only_key = None
  105. remote._config.translation_placeholders = None
  106. remote._config.entity_category = None
  107. remote._config.config_id = "test_remote"
  108. remote._attr_dps = []
  109. remote._attr_translation_key = None
  110. remote._attr_translation_placeholders = None
  111. remote._attr_is_on = True
  112. remote._attr_supported_features = 0
  113. remote._send_dp = MagicMock()
  114. remote._send_dp.id = 201
  115. remote._send_dp.get_values_to_set.side_effect = lambda dev, val, d: {201: val}
  116. remote._send_dp.async_set_value = AsyncMock()
  117. if has_receive:
  118. remote._receive_dp = MagicMock()
  119. remote._receive_dp.id = 202
  120. else:
  121. remote._receive_dp = None
  122. if has_control:
  123. remote._control_dp = MagicMock()
  124. remote._control_dp.get_values_to_set.side_effect = lambda dev, val, d: {
  125. "control": val
  126. }
  127. remote._control_dp.async_set_value = AsyncMock()
  128. else:
  129. remote._control_dp = None
  130. if has_delay:
  131. remote._delay_dp = MagicMock()
  132. remote._delay_dp.get_values_to_set.side_effect = lambda dev, val, d: {
  133. "delay": val
  134. }
  135. else:
  136. remote._delay_dp = None
  137. if has_type:
  138. remote._type_dp = MagicMock()
  139. remote._type_dp.get_values_to_set.side_effect = lambda dev, val, d: {
  140. "type": val
  141. }
  142. else:
  143. remote._type_dp = None
  144. remote._code_storage = MagicMock()
  145. remote._code_storage.async_load = AsyncMock(return_value={})
  146. remote._code_storage.async_save = AsyncMock()
  147. remote._code_storage.async_delay_save = MagicMock()
  148. remote._flag_storage = MagicMock()
  149. remote._flag_storage.async_load = AsyncMock(return_value={})
  150. remote._flag_storage.async_delay_save = MagicMock()
  151. remote._storage_loaded = False
  152. remote._codes = {}
  153. remote._flags = defaultdict(int)
  154. remote._lock = asyncio.Lock()
  155. return remote
  156. class TestExtractCodes:
  157. def test_b64_prefix(self):
  158. remote = _make_remote()
  159. remote._storage_loaded = True
  160. result = remote._extract_codes(["b64:AAAA"])
  161. assert result == [["AAAA"]]
  162. def test_rf_prefix(self):
  163. remote = _make_remote()
  164. remote._storage_loaded = True
  165. result = remote._extract_codes(["rf:BBBB"])
  166. assert result == [["rf:BBBB"]]
  167. def test_storage_lookup(self):
  168. remote = _make_remote()
  169. remote._storage_loaded = True
  170. remote._codes = {"tv": {"power": "CODE123"}}
  171. result = remote._extract_codes(["power"], subdevice="tv")
  172. assert result == [["CODE123"]]
  173. def test_storage_lookup_list(self):
  174. remote = _make_remote()
  175. remote._storage_loaded = True
  176. remote._codes = {"tv": {"power": ["CODE_ON", "CODE_OFF"]}}
  177. result = remote._extract_codes(["power"], subdevice="tv")
  178. assert result == [["CODE_ON", "CODE_OFF"]]
  179. def test_missing_subdevice_raises(self):
  180. remote = _make_remote()
  181. remote._storage_loaded = True
  182. with pytest.raises(ValueError, match="device must be specified"):
  183. remote._extract_codes(["power"])
  184. def test_missing_command_raises(self):
  185. remote = _make_remote()
  186. remote._storage_loaded = True
  187. remote._codes = {"tv": {}}
  188. with pytest.raises(ValueError, match="not found"):
  189. remote._extract_codes(["volume_up"], subdevice="tv")
  190. def test_multiple_commands(self):
  191. remote = _make_remote()
  192. remote._storage_loaded = True
  193. result = remote._extract_codes(["b64:AAA", "b64:BBB"])
  194. assert len(result) == 2
  195. class TestEncodeSendCode:
  196. def test_ir_default(self):
  197. remote = _make_remote()
  198. dps = remote._encode_send_code("TESTCODE", 300)
  199. assert 201 in dps
  200. payload = json.loads(dps[201])
  201. assert payload["control"] == CMD_SEND
  202. assert "TESTCODE" in payload["key1"]
  203. assert payload["delay"] == 300
  204. def test_rf_mode(self):
  205. remote = _make_remote()
  206. dps = remote._encode_send_code("RFCODE", 0, is_rf=True)
  207. assert 201 in dps
  208. payload = json.loads(dps[201])
  209. assert payload["control"] == CMD_SEND_RF
  210. assert payload["key1"]["code"] == "RFCODE"
  211. def test_with_control_dp(self):
  212. remote = _make_remote(has_control=True)
  213. dps = remote._encode_send_code("CODE", 100)
  214. assert "control" in dps
  215. assert dps["control"] == CMD_SEND
  216. assert 201 in dps
  217. assert dps[201] == "CODE"
  218. def test_with_control_and_delay(self):
  219. remote = _make_remote(has_control=True, has_delay=True)
  220. dps = remote._encode_send_code("CODE", 500)
  221. assert "delay" in dps
  222. assert dps["delay"] == 500
  223. def test_with_control_and_type(self):
  224. remote = _make_remote(has_control=True, has_type=True)
  225. dps = remote._encode_send_code("CODE", 100)
  226. assert "type" in dps
  227. assert dps["type"] == 0
  228. class TestAsyncSendCommand:
  229. @pytest.mark.asyncio
  230. async def test_send_b64_command(self):
  231. remote = _make_remote()
  232. await remote.async_send_command(["b64:TESTCODE"], num_repeats=1)
  233. remote._device.async_set_properties.assert_awaited_once()
  234. assert remote._storage_loaded is True
  235. @pytest.mark.asyncio
  236. async def test_send_rf_command(self):
  237. remote = _make_remote()
  238. await remote.async_send_command(["rf:RFCODE"], num_repeats=1)
  239. call_args = remote._device.async_set_properties.call_args[0][0]
  240. payload = json.loads(call_args[201])
  241. assert payload["control"] == CMD_SEND_RF
  242. @pytest.mark.asyncio
  243. async def test_send_stored_command(self):
  244. remote = _make_remote()
  245. remote._codes = {"tv": {"power": "STORED_CODE"}}
  246. await remote.async_send_command(["power"], device="tv", num_repeats=1)
  247. remote._device.async_set_properties.assert_awaited_once()
  248. @pytest.mark.asyncio
  249. async def test_send_toggle_command(self):
  250. remote = _make_remote()
  251. remote._codes = {"tv": {"power": ["ON_CODE", "OFF_CODE"]}}
  252. await remote.async_send_command(["power"], device="tv", num_repeats=1)
  253. # First call uses flag=0 (ON_CODE)
  254. remote._device.async_set_properties.assert_awaited_once()
  255. # Flag should have been toggled
  256. assert remote._flags["tv"] == 1
  257. remote._flag_storage.async_delay_save.assert_called_once()
  258. @pytest.mark.asyncio
  259. async def test_send_invalid_command_raises(self):
  260. remote = _make_remote()
  261. remote._codes = {"tv": {}}
  262. with pytest.raises(ValueError):
  263. await remote.async_send_command(["missing"], device="tv", num_repeats=1)
  264. @pytest.mark.asyncio
  265. async def test_loads_storage_on_first_send(self):
  266. remote = _make_remote()
  267. assert remote._storage_loaded is False
  268. await remote.async_send_command(["b64:CODE"], num_repeats=1)
  269. assert remote._storage_loaded is True
  270. remote._code_storage.async_load.assert_awaited_once()
  271. class TestAsyncDeleteCommand:
  272. @pytest.mark.asyncio
  273. async def test_delete_command(self):
  274. remote = _make_remote()
  275. remote._codes = {"tv": {"power": "CODE", "volume": "CODE2"}}
  276. remote._storage_loaded = True
  277. await remote.async_delete_command(command=["power"], device="tv")
  278. assert "power" not in remote._codes["tv"]
  279. assert "volume" in remote._codes["tv"]
  280. @pytest.mark.asyncio
  281. async def test_delete_last_command_cleans_up(self):
  282. remote = _make_remote()
  283. remote._codes = {"tv": {"power": "CODE"}}
  284. remote._flags["tv"] = 1
  285. remote._storage_loaded = True
  286. await remote.async_delete_command(command=["power"], device="tv")
  287. assert "tv" not in remote._codes
  288. remote._flag_storage.async_delay_save.assert_called_once()
  289. @pytest.mark.asyncio
  290. async def test_delete_missing_device_raises(self):
  291. remote = _make_remote()
  292. remote._storage_loaded = True
  293. with pytest.raises(ValueError, match="Device not found"):
  294. await remote.async_delete_command(command=["power"], device="unknown")
  295. @pytest.mark.asyncio
  296. async def test_delete_missing_command_raises(self):
  297. remote = _make_remote()
  298. remote._codes = {"tv": {}}
  299. remote._storage_loaded = True
  300. with pytest.raises(ValueError, match="Command not found"):
  301. await remote.async_delete_command(command=["missing"], device="tv")
  302. @pytest.mark.asyncio
  303. async def test_delete_partial_missing_logs_error(self):
  304. remote = _make_remote()
  305. remote._codes = {"tv": {"power": "CODE"}}
  306. remote._storage_loaded = True
  307. # "power" exists, "missing" does not — partial failure, no raise
  308. await remote.async_delete_command(command=["power", "missing"], device="tv")
  309. assert "power" not in remote._codes.get("tv", {})
  310. class TestAsyncLearnCommand:
  311. @pytest.mark.asyncio
  312. async def test_learn_ir_command(self):
  313. remote = _make_remote()
  314. remote._storage_loaded = True
  315. remote._receive_dp.get_value.side_effect = [None, "LEARNED_CODE"]
  316. with patch("custom_components.tuya_local.remote.persistent_notification"):
  317. with patch(
  318. "custom_components.tuya_local.remote.asyncio.sleep",
  319. new_callable=AsyncMock,
  320. ):
  321. await remote.async_learn_command(
  322. command=["power"], device="tv", alternative=False
  323. )
  324. assert remote._codes["tv"]["power"] == "LEARNED_CODE"
  325. remote._code_storage.async_save.assert_awaited_once()
  326. @pytest.mark.asyncio
  327. async def test_learn_timeout_raises(self):
  328. remote = _make_remote()
  329. remote._storage_loaded = True
  330. remote._receive_dp.get_value.return_value = None
  331. with patch("custom_components.tuya_local.remote.persistent_notification"):
  332. with patch(
  333. "custom_components.tuya_local.remote.asyncio.sleep",
  334. new_callable=AsyncMock,
  335. ):
  336. with patch(
  337. "custom_components.tuya_local.remote.dt_util.utcnow"
  338. ) as mock_now:
  339. from datetime import datetime, timedelta
  340. start = datetime(2026, 1, 1)
  341. # First call returns start, then jumps past timeout
  342. mock_now.side_effect = [
  343. start,
  344. start,
  345. start + timedelta(seconds=31),
  346. ]
  347. with pytest.raises(TimeoutError):
  348. await remote.async_learn_command(
  349. command=["power"], device="tv", alternative=False
  350. )
  351. class TestAsyncLoadStorage:
  352. @pytest.mark.asyncio
  353. async def test_load_storage(self):
  354. remote = _make_remote()
  355. remote._code_storage.async_load.return_value = {"tv": {"power": "CODE"}}
  356. remote._flag_storage.async_load.return_value = {"tv": 1}
  357. await remote._async_load_storage()
  358. assert remote._storage_loaded is True
  359. assert remote._codes == {"tv": {"power": "CODE"}}
  360. assert remote._flags["tv"] == 1
  361. @pytest.mark.asyncio
  362. async def test_load_empty_storage(self):
  363. remote = _make_remote()
  364. remote._code_storage.async_load.return_value = None
  365. remote._flag_storage.async_load.return_value = None
  366. await remote._async_load_storage()
  367. assert remote._storage_loaded is True
  368. assert remote._codes == {}