Home Assistant Unofficial Reference 2024.12.1
remote.py
Go to the documentation of this file.
1 """Support for Broadlink remotes."""
2 
3 import asyncio
4 from base64 import b64encode
5 from collections import defaultdict
6 from collections.abc import Iterable
7 from datetime import timedelta
8 from itertools import product
9 import logging
10 from typing import Any
11 
12 from broadlink.exceptions import (
13  AuthorizationError,
14  BroadlinkException,
15  NetworkTimeoutError,
16  ReadError,
17  StorageError,
18 )
19 import voluptuous as vol
20 
21 from homeassistant.components import persistent_notification
23  ATTR_ALTERNATIVE,
24  ATTR_COMMAND_TYPE,
25  ATTR_DELAY_SECS,
26  ATTR_DEVICE,
27  ATTR_NUM_REPEATS,
28  DEFAULT_DELAY_SECS,
29  DOMAIN as RM_DOMAIN,
30  SERVICE_DELETE_COMMAND,
31  SERVICE_LEARN_COMMAND,
32  SERVICE_SEND_COMMAND,
33  RemoteEntity,
34  RemoteEntityFeature,
35 )
36 from homeassistant.config_entries import ConfigEntry
37 from homeassistant.const import ATTR_COMMAND, STATE_OFF
38 from homeassistant.core import HomeAssistant, callback
39 from homeassistant.helpers import config_validation as cv
40 from homeassistant.helpers.entity_platform import AddEntitiesCallback
41 from homeassistant.helpers.restore_state import RestoreEntity
42 from homeassistant.helpers.storage import Store
43 from homeassistant.util import dt as dt_util
44 
45 from .const import DOMAIN
46 from .entity import BroadlinkEntity
47 from .helpers import data_packet
48 
49 _LOGGER = logging.getLogger(__name__)
50 
51 LEARNING_TIMEOUT = timedelta(seconds=30)
52 
53 COMMAND_TYPE_IR = "ir"
54 COMMAND_TYPE_RF = "rf"
55 COMMAND_TYPES = [COMMAND_TYPE_IR, COMMAND_TYPE_RF]
56 
57 CODE_STORAGE_VERSION = 1
58 FLAG_STORAGE_VERSION = 1
59 
60 CODE_SAVE_DELAY = 15
61 FLAG_SAVE_DELAY = 15
62 
63 COMMAND_SCHEMA = vol.Schema(
64  {
65  vol.Required(ATTR_COMMAND): vol.All(
66  cv.ensure_list, [vol.All(cv.string, vol.Length(min=1))], vol.Length(min=1)
67  ),
68  },
69  extra=vol.ALLOW_EXTRA,
70 )
71 
72 SERVICE_SEND_SCHEMA = COMMAND_SCHEMA.extend(
73  {
74  vol.Optional(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
75  vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
76  }
77 )
78 
79 SERVICE_LEARN_SCHEMA = COMMAND_SCHEMA.extend(
80  {
81  vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
82  vol.Optional(ATTR_COMMAND_TYPE, default=COMMAND_TYPE_IR): vol.In(COMMAND_TYPES),
83  vol.Optional(ATTR_ALTERNATIVE, default=False): cv.boolean,
84  }
85 )
86 
87 SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend(
88  {vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1))}
89 )
90 
91 
93  hass: HomeAssistant,
94  config_entry: ConfigEntry,
95  async_add_entities: AddEntitiesCallback,
96 ) -> None:
97  """Set up a Broadlink remote."""
98  device = hass.data[DOMAIN].devices[config_entry.entry_id]
99  remote = BroadlinkRemote(
100  device,
101  Store(hass, CODE_STORAGE_VERSION, f"broadlink_remote_{device.unique_id}_codes"),
102  Store(hass, FLAG_STORAGE_VERSION, f"broadlink_remote_{device.unique_id}_flags"),
103  )
104  async_add_entities([remote], False)
105 
106 
108  """Representation of a Broadlink remote."""
109 
110  _attr_has_entity_name = True
111  _attr_name = None
112 
113  def __init__(self, device, codes, flags):
114  """Initialize the remote."""
115  super().__init__(device)
116  self._code_storage_code_storage = codes
117  self._flag_storage_flag_storage = flags
118  self._storage_loaded_storage_loaded = False
119  self._codes_codes = {}
120  self._flags_flags = defaultdict(int)
121  self._lock_lock = asyncio.Lock()
122 
123  self._attr_is_on_attr_is_on = True
124  self._attr_supported_features_attr_supported_features = (
125  RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND
126  )
127  self._attr_unique_id_attr_unique_id = device.unique_id
128 
129  def _extract_codes(self, commands, device=None):
130  """Extract a list of codes.
131 
132  If the command starts with `b64:`, extract the code from it.
133  Otherwise, extract the code from storage, using the command and
134  device as keys.
135 
136  The codes are returned in sublists. For toggle commands, the
137  sublist contains two codes that must be sent alternately with
138  each call.
139  """
140  code_list = []
141  for cmd in commands:
142  if cmd.startswith("b64:"):
143  codes = [cmd[4:]]
144 
145  else:
146  if device is None:
147  raise ValueError("You need to specify a device")
148 
149  try:
150  codes = self._codes_codes[device][cmd]
151  except KeyError as err:
152  raise ValueError(f"Command not found: {cmd!r}") from err
153 
154  if isinstance(codes, list):
155  codes = codes[:]
156  else:
157  codes = [codes]
158 
159  for idx, code in enumerate(codes):
160  try:
161  codes[idx] = data_packet(code)
162  except ValueError as err:
163  raise ValueError(f"Invalid code: {code!r}") from err
164 
165  code_list.append(codes)
166  return code_list
167 
168  @callback
169  def _get_codes(self):
170  """Return a dictionary of codes."""
171  return self._codes_codes
172 
173  @callback
174  def _get_flags(self):
175  """Return a dictionary of toggle flags.
176 
177  A toggle flag indicates whether the remote should send an
178  alternative code.
179  """
180  return self._flags_flags
181 
182  async def async_added_to_hass(self) -> None:
183  """Call when the remote is added to hass."""
184  state = await self.async_get_last_stateasync_get_last_state()
185  self._attr_is_on_attr_is_on = state is None or state.state != STATE_OFF
186  await super().async_added_to_hass()
187 
188  async def async_turn_on(self, **kwargs: Any) -> None:
189  """Turn on the remote."""
190  self._attr_is_on_attr_is_on = True
191  self.async_write_ha_stateasync_write_ha_state()
192 
193  async def async_turn_off(self, **kwargs: Any) -> None:
194  """Turn off the remote."""
195  self._attr_is_on_attr_is_on = False
196  self.async_write_ha_stateasync_write_ha_state()
197 
198  async def _async_load_storage(self):
199  """Load code and flag storage from disk."""
200  # Exception is intentionally not trapped to
201  # provide feedback if something fails.
202  self._codes_codes.update(await self._code_storage_code_storage.async_load() or {})
203  self._flags_flags.update(await self._flag_storage_flag_storage.async_load() or {})
204  self._storage_loaded_storage_loaded = True
205 
206  async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
207  """Send a list of commands to a device."""
208  kwargs[ATTR_COMMAND] = command
209  kwargs = SERVICE_SEND_SCHEMA(kwargs)
210  commands = kwargs[ATTR_COMMAND]
211  subdevice = kwargs.get(ATTR_DEVICE)
212  repeat = kwargs[ATTR_NUM_REPEATS]
213  delay = kwargs[ATTR_DELAY_SECS]
214  service = f"{RM_DOMAIN}.{SERVICE_SEND_COMMAND}"
215  device = self._device_device
216 
217  if not self._attr_is_on_attr_is_on:
218  _LOGGER.warning(
219  "%s canceled: %s entity is turned off", service, self.entity_identity_id
220  )
221  return
222 
223  if not self._storage_loaded_storage_loaded:
224  await self._async_load_storage_async_load_storage()
225 
226  try:
227  code_list = self._extract_codes_extract_codes(commands, subdevice)
228  except ValueError as err:
229  _LOGGER.error("Failed to call %s: %s", service, err)
230  raise
231 
232  rf_flags = {0xB2, 0xD7}
233  if not hasattr(device.api, "sweep_frequency") and any(
234  c[0] in rf_flags for codes in code_list for c in codes
235  ):
236  err_msg = f"{self.entity_id} doesn't support sending RF commands"
237  _LOGGER.error("Failed to call %s: %s", service, err_msg)
238  raise ValueError(err_msg)
239 
240  at_least_one_sent = False
241  for _, codes in product(range(repeat), code_list):
242  if at_least_one_sent:
243  await asyncio.sleep(delay)
244 
245  if len(codes) > 1:
246  code = codes[self._flags_flags[subdevice]]
247  else:
248  code = codes[0]
249 
250  try:
251  await device.async_request(device.api.send_data, code)
252  except (BroadlinkException, OSError) as err:
253  _LOGGER.error("Error during %s: %s", service, err)
254  break
255 
256  if len(codes) > 1:
257  self._flags_flags[subdevice] ^= 1
258  at_least_one_sent = True
259 
260  if at_least_one_sent:
261  self._flag_storage_flag_storage.async_delay_save(self._get_flags_get_flags, FLAG_SAVE_DELAY)
262 
263  async def async_learn_command(self, **kwargs: Any) -> None:
264  """Learn a list of commands from a remote."""
265  kwargs = SERVICE_LEARN_SCHEMA(kwargs)
266  commands = kwargs[ATTR_COMMAND]
267  command_type = kwargs[ATTR_COMMAND_TYPE]
268  subdevice = kwargs[ATTR_DEVICE]
269  toggle = kwargs[ATTR_ALTERNATIVE]
270  service = f"{RM_DOMAIN}.{SERVICE_LEARN_COMMAND}"
271  device = self._device_device
272 
273  if not self._attr_is_on_attr_is_on:
274  _LOGGER.warning(
275  "%s canceled: %s entity is turned off", service, self.entity_identity_id
276  )
277  return
278 
279  if not self._storage_loaded_storage_loaded:
280  await self._async_load_storage_async_load_storage()
281 
282  async with self._lock_lock:
283  if command_type == COMMAND_TYPE_IR:
284  learn_command = self._async_learn_ir_command_async_learn_ir_command
285 
286  elif hasattr(device.api, "sweep_frequency"):
287  learn_command = self._async_learn_rf_command_async_learn_rf_command
288 
289  else:
290  err_msg = f"{self.entity_id} doesn't support learning RF commands"
291  _LOGGER.error("Failed to call %s: %s", service, err_msg)
292  raise ValueError(err_msg)
293 
294  should_store = False
295 
296  for command in commands:
297  try:
298  code = await learn_command(command)
299  if toggle:
300  code = [code, await learn_command(command)]
301 
302  except (AuthorizationError, NetworkTimeoutError, OSError) as err:
303  _LOGGER.error("Failed to learn '%s': %s", command, err)
304  break
305 
306  except BroadlinkException as err:
307  _LOGGER.error("Failed to learn '%s': %s", command, err)
308  continue
309 
310  self._codes_codes.setdefault(subdevice, {}).update({command: code})
311  should_store = True
312 
313  if should_store:
314  await self._code_storage_code_storage.async_save(self._codes_codes)
315 
316  async def _async_learn_ir_command(self, command):
317  """Learn an infrared command."""
318  device = self._device_device
319 
320  try:
321  await device.async_request(device.api.enter_learning)
322 
323  except (BroadlinkException, OSError) as err:
324  _LOGGER.debug("Failed to enter learning mode: %s", err)
325  raise
326 
327  persistent_notification.async_create(
328  self.hasshass,
329  f"Press the '{command}' button.",
330  title="Learn command",
331  notification_id="learn_command",
332  )
333 
334  try:
335  start_time = dt_util.utcnow()
336  while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
337  await asyncio.sleep(1)
338  try:
339  code = await device.async_request(device.api.check_data)
340  except (ReadError, StorageError):
341  continue
342  return b64encode(code).decode("utf8")
343 
344  raise TimeoutError(
345  "No infrared code received within "
346  f"{LEARNING_TIMEOUT.total_seconds()} seconds"
347  )
348 
349  finally:
350  persistent_notification.async_dismiss(
351  self.hasshass, notification_id="learn_command"
352  )
353 
354  async def _async_learn_rf_command(self, command):
355  """Learn a radiofrequency command."""
356  device = self._device_device
357 
358  try:
359  await device.async_request(device.api.sweep_frequency)
360 
361  except (BroadlinkException, OSError) as err:
362  _LOGGER.debug("Failed to sweep frequency: %s", err)
363  raise
364 
365  persistent_notification.async_create(
366  self.hasshass,
367  f"Press and hold the '{command}' button.",
368  title="Sweep frequency",
369  notification_id="sweep_frequency",
370  )
371 
372  try:
373  start_time = dt_util.utcnow()
374  while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
375  await asyncio.sleep(1)
376  is_found, frequency = await device.async_request(
377  device.api.check_frequency
378  )
379  if is_found:
380  _LOGGER.debug("Radiofrequency detected: %s MHz", frequency)
381  break
382  else:
383  await device.async_request(device.api.cancel_sweep_frequency)
384  raise TimeoutError(
385  "No radiofrequency found within "
386  f"{LEARNING_TIMEOUT.total_seconds()} seconds"
387  )
388 
389  finally:
390  persistent_notification.async_dismiss(
391  self.hasshass, notification_id="sweep_frequency"
392  )
393 
394  await asyncio.sleep(1)
395 
396  try:
397  await device.async_request(device.api.find_rf_packet)
398 
399  except (BroadlinkException, OSError) as err:
400  _LOGGER.debug("Failed to enter learning mode: %s", err)
401  raise
402 
403  persistent_notification.async_create(
404  self.hasshass,
405  f"Press the '{command}' button again.",
406  title="Learn command",
407  notification_id="learn_command",
408  )
409 
410  try:
411  start_time = dt_util.utcnow()
412  while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT:
413  await asyncio.sleep(1)
414  try:
415  code = await device.async_request(device.api.check_data)
416  except (ReadError, StorageError):
417  continue
418  return b64encode(code).decode("utf8")
419 
420  raise TimeoutError(
421  "No radiofrequency code received within "
422  f"{LEARNING_TIMEOUT.total_seconds()} seconds"
423  )
424 
425  finally:
426  persistent_notification.async_dismiss(
427  self.hasshass, notification_id="learn_command"
428  )
429 
430  async def async_delete_command(self, **kwargs: Any) -> None:
431  """Delete a list of commands from a remote."""
432  kwargs = SERVICE_DELETE_SCHEMA(kwargs)
433  commands = kwargs[ATTR_COMMAND]
434  subdevice = kwargs[ATTR_DEVICE]
435  service = f"{RM_DOMAIN}.{SERVICE_DELETE_COMMAND}"
436 
437  if not self._attr_is_on_attr_is_on:
438  _LOGGER.warning(
439  "%s canceled: %s entity is turned off",
440  service,
441  self.entity_identity_id,
442  )
443  return
444 
445  if not self._storage_loaded_storage_loaded:
446  await self._async_load_storage_async_load_storage()
447 
448  try:
449  codes = self._codes_codes[subdevice]
450  except KeyError as err:
451  err_msg = f"Device not found: {subdevice!r}"
452  _LOGGER.error("Failed to call %s. %s", service, err_msg)
453  raise ValueError(err_msg) from err
454 
455  cmds_not_found = []
456  for command in commands:
457  try:
458  del codes[command]
459  except KeyError:
460  cmds_not_found.append(command)
461 
462  if cmds_not_found:
463  if len(cmds_not_found) == 1:
464  err_msg = f"Command not found: {cmds_not_found[0]!r}"
465  else:
466  err_msg = f"Commands not found: {cmds_not_found!r}"
467 
468  if len(cmds_not_found) == len(commands):
469  _LOGGER.error("Failed to call %s. %s", service, err_msg)
470  raise ValueError(err_msg)
471 
472  _LOGGER.error("Error during %s. %s", service, err_msg)
473 
474  # Clean up
475  if not codes:
476  del self._codes_codes[subdevice]
477  if self._flags_flags.pop(subdevice, None) is not None:
478  self._flag_storage_flag_storage.async_delay_save(self._get_flags_get_flags, FLAG_SAVE_DELAY)
479 
480  self._code_storage_code_storage.async_delay_save(self._get_codes_get_codes, CODE_SAVE_DELAY)
None learn_command(self, **Any kwargs)
Definition: __init__.py:237
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None async_load(HomeAssistant hass)
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)
Definition: storage.py:444
None async_save(self, _T data)
Definition: storage.py:424