remote.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. """
  2. Implementation of Tuya remote control devices
  3. Based on broadlink integration for code saving under HA storage
  4. """
  5. import asyncio
  6. import json
  7. import logging
  8. from collections import defaultdict
  9. from collections.abc import Iterable
  10. from datetime import timedelta
  11. from itertools import product
  12. from typing import Any
  13. import voluptuous as vol
  14. from homeassistant.components import persistent_notification
  15. from homeassistant.components.remote import (
  16. ATTR_ALTERNATIVE,
  17. ATTR_COMMAND_TYPE,
  18. ATTR_DELAY_SECS,
  19. ATTR_DEVICE,
  20. ATTR_NUM_REPEATS,
  21. DEFAULT_DELAY_SECS,
  22. SERVICE_DELETE_COMMAND,
  23. SERVICE_LEARN_COMMAND,
  24. SERVICE_SEND_COMMAND,
  25. RemoteEntity,
  26. RemoteEntityFeature,
  27. )
  28. from homeassistant.components.remote import (
  29. DOMAIN as RM_DOMAIN,
  30. )
  31. from homeassistant.const import ATTR_COMMAND
  32. from homeassistant.helpers import config_validation as cv
  33. from homeassistant.helpers.storage import Store
  34. from homeassistant.util import dt as dt_util
  35. # from tinytuya.Contrib.IRRemoteControlDevice import (
  36. # base64_to_pulses,
  37. # pulses_to_pronto,
  38. # pulses_to_width_encoded,
  39. # )
  40. from .device import TuyaLocalDevice
  41. from .entity import TuyaLocalEntity
  42. from .helpers.config import async_tuya_setup_platform
  43. from .helpers.device_config import TuyaEntityConfig
  44. _LOGGER = logging.getLogger(__name__)
  45. CODE_STORAGE_VERSION = 1
  46. FLAG_STORAGE_VERSION = 1
  47. CODE_SAVE_DELAY = 15
  48. FLAG_SAVE_DELAY = 15
  49. LEARNING_TIMEOUT = timedelta(seconds=30)
  50. # These commands seem to be standard for all devices
  51. CMD_SEND = "send_ir"
  52. CMD_SEND_RF = "rfstudy_send"
  53. CMD_LEARN = "study"
  54. CMD_ENDLEARN = "study_exit"
  55. CMD_STUDYKEY = "study_key"
  56. CMD_STUDYRF = "rf_study"
  57. CMD_ENDSTUDYRF = "rfstudy_exit"
  58. COMMAND_SCHEMA = vol.Schema(
  59. {
  60. vol.Required(ATTR_COMMAND): vol.All(
  61. cv.ensure_list, [vol.All(cv.string, vol.Length(min=1))], vol.Length(min=1)
  62. ),
  63. },
  64. extra=vol.ALLOW_EXTRA,
  65. )
  66. SERVICE_SEND_SCHEMA = COMMAND_SCHEMA.extend(
  67. {
  68. vol.Optional(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
  69. vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
  70. }
  71. )
  72. SERVICE_LEARN_SCHEMA = COMMAND_SCHEMA.extend(
  73. {
  74. vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
  75. vol.Optional(ATTR_ALTERNATIVE, default=False): cv.boolean,
  76. }
  77. )
  78. SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend(
  79. {
  80. vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
  81. }
  82. )
  83. async def async_setup_entry(hass, config_entry, async_add_entities):
  84. config = {**config_entry.data, **config_entry.options}
  85. await async_tuya_setup_platform(
  86. hass,
  87. async_add_entities,
  88. config,
  89. "remote",
  90. TuyaLocalRemote,
  91. )
  92. class TuyaLocalRemote(TuyaLocalEntity, RemoteEntity):
  93. """Representation of a Tuya Remote entity."""
  94. def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
  95. """
  96. Initialise the remote device.
  97. Args:
  98. device (TuyaLocalDevice): The device API instance.
  99. config (TuyaEntityConfig): The entity config.
  100. """
  101. super().__init__()
  102. dps_map = self._init_begin(device, config)
  103. self._send_dp = dps_map.pop("send", None)
  104. self._receive_dp = dps_map.pop("receive", None)
  105. # Some remotes split out the control (command) into its own dp and just send raw codes in send
  106. self._control_dp = dps_map.pop("control", None)
  107. self._delay_dp = dps_map.pop("delay", None)
  108. self._type_dp = dps_map.pop("code_type", None)
  109. self._init_end(dps_map)
  110. if self._receive_dp:
  111. self._attr_supported_features |= (
  112. RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND
  113. )
  114. self._code_storage = Store(
  115. device._hass,
  116. CODE_STORAGE_VERSION,
  117. f"tuya_local_remote_{device.unique_id}_codes",
  118. )
  119. self._flag_storage = Store(
  120. device._hass,
  121. FLAG_STORAGE_VERSION,
  122. f"tuya_local_remote_{device.unique_id}_flags",
  123. )
  124. self._storage_loaded = False
  125. self._codes = {}
  126. self._flags = defaultdict(int)
  127. self._lock = asyncio.Lock()
  128. self._attr_is_on = True
  129. async def _async_load_storage(self):
  130. """Load stored codes and flags from disk."""
  131. self._codes.update(await self._code_storage.async_load() or {})
  132. self._flags.update(await self._flag_storage.async_load() or {})
  133. self._storage_loaded = True
  134. def _extract_codes(self, commands, subdevice=None):
  135. """Extract a list of remote codes.
  136. If the command starts with 'b64:', extract the IR code from it.
  137. If the command starts with 'rf:', keep it as-is so that
  138. _encode_send_code can apply the correct RF payload format.
  139. Otherwise use the command and optionally subdevice as keys to extract the
  140. actual command from storage.
  141. The commands are returned in sublists. For toggle commands, the sublist
  142. may contain two codes that must be sent alternately with each call."""
  143. code_list = []
  144. for cmd in commands:
  145. if cmd.startswith("b64:"):
  146. codes = [cmd[4:]]
  147. elif cmd.startswith("rf:"):
  148. codes = [cmd]
  149. else:
  150. if subdevice is None:
  151. raise ValueError("device must be specified")
  152. try:
  153. codes = self._codes[subdevice][cmd]
  154. except KeyError as err:
  155. raise ValueError(
  156. f"Command {repr(cmd)} not found for {subdevice}"
  157. ) from err
  158. if isinstance(codes, list):
  159. codes = codes[:]
  160. else:
  161. codes = [codes]
  162. for idx, code in enumerate(codes):
  163. try:
  164. codes[idx] = code
  165. except ValueError as err:
  166. raise ValueError(f"Invalid code: {repr(code)}") from err
  167. code_list.append(codes)
  168. return code_list
  169. def _encode_send_code(self, code, delay, is_rf=False):
  170. """Encode a remote command into dps values to send.
  171. Set is_rf=True to use the RF sub-GHz payload format.
  172. The default (is_rf=False) uses the IR payload format.
  173. Based on https://github.com/jasonacox/tinytuya/issues/74 and
  174. the docs it references, there are two kinds of IR devices.
  175. 1. separate dps for control, code, study,...
  176. 2. single dp (201) for send_ir, which takes JSON input,
  177. including control, code, delay, etc, and another for
  178. study_ir (202) that receives the codes in study mode.
  179. RF devices also use a single dp (201) but with a different
  180. JSON payload using control 'rfstudy_send'.
  181. """
  182. dps = {}
  183. if self._control_dp:
  184. # control and code are sent in separate dps.
  185. dps = dps | self._control_dp.get_values_to_set(self._device, CMD_SEND, dps)
  186. dps = dps | self._send_dp.get_values_to_set(self._device, code, dps)
  187. if self._delay_dp:
  188. dps = dps | self._delay_dp.get_values_to_set(self._device, delay, dps)
  189. if self._type_dp:
  190. dps = dps | self._type_dp.get_values_to_set(self._device, 0, dps)
  191. elif is_rf:
  192. dps = dps | self._send_dp.get_values_to_set(
  193. self._device,
  194. json.dumps(
  195. {
  196. "control": CMD_SEND_RF,
  197. "rf_type": "sub_2g",
  198. "mode": 0,
  199. "key1": {
  200. "times": 6,
  201. "intervals": 0,
  202. "ver": "2",
  203. "delay": 0,
  204. "code": code,
  205. },
  206. "feq": 0,
  207. "rate": 0,
  208. "ver": "2",
  209. },
  210. ),
  211. dps,
  212. )
  213. else:
  214. dps = dps | self._send_dp.get_values_to_set(
  215. self._device,
  216. json.dumps(
  217. {
  218. "control": CMD_SEND,
  219. "head": "",
  220. # leading zero means use head, any other leading character is discarded.
  221. "key1": "1" + code,
  222. "type": 0,
  223. "delay": int(delay),
  224. },
  225. ),
  226. dps,
  227. )
  228. return dps
  229. async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
  230. """Send remote commands"""
  231. kwargs[ATTR_COMMAND] = command
  232. kwargs = SERVICE_SEND_SCHEMA(kwargs)
  233. subdevice = kwargs.get(ATTR_DEVICE)
  234. repeat = kwargs.get(ATTR_NUM_REPEATS)
  235. delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) * 1000
  236. service = f"{RM_DOMAIN}.{SERVICE_SEND_COMMAND}"
  237. if not self._storage_loaded:
  238. await self._async_load_storage()
  239. try:
  240. code_list = self._extract_codes(command, subdevice)
  241. except ValueError as err:
  242. _LOGGER.error("Failed to call %s: %s", service, err)
  243. raise
  244. at_least_one_sent = False
  245. for _, codes in product(range(repeat), code_list):
  246. if at_least_one_sent:
  247. await asyncio.sleep(delay)
  248. if len(codes) > 1:
  249. code = codes[self._flags[subdevice]]
  250. else:
  251. code = codes[0]
  252. if code.startswith("rf:"):
  253. dps_to_set = self._encode_send_code(code[3:], delay, is_rf=True)
  254. else:
  255. dps_to_set = self._encode_send_code(code, delay)
  256. await self._device.async_set_properties(dps_to_set)
  257. if len(codes) > 1:
  258. self._flags[subdevice] ^= 1
  259. at_least_one_sent = True
  260. if at_least_one_sent:
  261. self._flag_storage.async_delay_save(lambda: self._flags, FLAG_SAVE_DELAY)
  262. async def async_learn_command(self, **kwargs: Any) -> None:
  263. """Learn a list of commands from a remote."""
  264. kwargs = SERVICE_LEARN_SCHEMA(kwargs)
  265. commands = kwargs[ATTR_COMMAND]
  266. subdevice = kwargs[ATTR_DEVICE]
  267. toggle = kwargs[ATTR_ALTERNATIVE]
  268. is_rf = kwargs.get(ATTR_COMMAND_TYPE) == "rf"
  269. if not self._storage_loaded:
  270. await self._async_load_storage()
  271. async with self._lock:
  272. should_store = False
  273. for command in commands:
  274. code = await self._async_learn_command(command, is_rf=is_rf)
  275. _LOGGER.info("Learning %s for %s: %s", command, subdevice, code)
  276. # pulses = base64_to_pulses(code)
  277. # _LOGGER.debug("= pronto code: %s", pulses_to_pronto(pulses))
  278. # _LOGGER.debug("= width encoded: %s", pulses_to_width_encoded(pulses))
  279. if toggle:
  280. code = [code, await self._async_learn_command(command, is_rf=is_rf)]
  281. self._codes.setdefault(subdevice, {}).update({command: code})
  282. should_store = True
  283. if should_store:
  284. await self._code_storage.async_save(self._codes)
  285. async def _async_learn_command(self, command, is_rf=False):
  286. """Learn a single command"""
  287. service = f"{RM_DOMAIN}.{SERVICE_LEARN_COMMAND}"
  288. if is_rf:
  289. cmd_start = json.dumps(
  290. {
  291. "control": CMD_STUDYRF,
  292. "rf_type": "sub_2g",
  293. "study_feq": "0",
  294. "ver": "2",
  295. }
  296. )
  297. cmd_end = json.dumps(
  298. {
  299. "control": CMD_ENDSTUDYRF,
  300. "rf_type": "sub_2g",
  301. "study_feq": "0",
  302. "ver": "2",
  303. }
  304. )
  305. if self._control_dp:
  306. await self._control_dp.async_set_value(self._device, CMD_LEARN)
  307. elif is_rf:
  308. await self._send_dp.async_set_value(self._device, cmd_start)
  309. else:
  310. await self._send_dp.async_set_value(
  311. self._device,
  312. json.dumps({"control": CMD_LEARN}),
  313. )
  314. persistent_notification.async_create(
  315. self._device._hass,
  316. f"Press the '{command}' button.",
  317. title="Learn command",
  318. notification_id="learn_command",
  319. )
  320. try:
  321. start_time = dt_util.utcnow()
  322. while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
  323. await asyncio.sleep(1)
  324. code = self._receive_dp.get_value(self._device)
  325. if code is not None:
  326. self._device.anticipate_property_value(self._receive_dp.id, None)
  327. return "rf:" + code if is_rf else code
  328. _LOGGER.warning("Timed out without receiving code in %s", service)
  329. raise TimeoutError(
  330. f"No remote code received within {LEARNING_TIMEOUT.total_seconds()} seconds",
  331. )
  332. finally:
  333. persistent_notification.async_dismiss(
  334. self._device._hass, notification_id="learn_command"
  335. )
  336. if self._control_dp:
  337. await self._control_dp.async_set_value(
  338. self._device,
  339. CMD_ENDLEARN,
  340. )
  341. elif is_rf:
  342. await self._send_dp.async_set_value(self._device, cmd_end)
  343. else:
  344. await self._send_dp.async_set_value(
  345. self._device,
  346. json.dumps({"control": CMD_ENDLEARN}),
  347. )
  348. async def async_delete_command(self, **kwargs: Any) -> None:
  349. """Delete a list of commands from a remote."""
  350. kwargs = SERVICE_DELETE_SCHEMA(kwargs)
  351. commands = kwargs[ATTR_COMMAND]
  352. subdevice = kwargs[ATTR_DEVICE]
  353. service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}"
  354. if not self._storage_loaded:
  355. await self._async_load_storage()
  356. try:
  357. codes = self._codes[subdevice]
  358. except KeyError as err:
  359. err_msg = f"Device not found {repr(subdevice)}"
  360. _LOGGER.error("Failed to call %s. %s", service, err_msg)
  361. raise ValueError(err_msg) from err
  362. cmds_not_found = []
  363. for command in commands:
  364. try:
  365. del codes[command]
  366. except KeyError:
  367. cmds_not_found.append(command)
  368. if cmds_not_found:
  369. if len(cmds_not_found) == 1:
  370. err_msg = f"Command not found: {repr(cmds_not_found[0])}"
  371. else:
  372. err_msg = f"Commands not found: {repr(cmds_not_found)}"
  373. if len(cmds_not_found) == len(commands):
  374. _LOGGER.error("Failed to call %s. %s", service, err_msg)
  375. raise ValueError(err_msg)
  376. _LOGGER.error("Error during %s. %s", service, err_msg)
  377. # Clean up
  378. if not codes:
  379. del self._codes[subdevice]
  380. if self._flags.pop(subdevice, None) is not None:
  381. self._flag_storage.async_delay_save(
  382. lambda: self._flags, FLAG_SAVE_DELAY
  383. )
  384. self._code_storage.async_delay_save(lambda: self._codes, CODE_SAVE_DELAY)