Home Assistant Unofficial Reference 2024.12.1
device.py
Go to the documentation of this file.
1 """Support for Broadlink devices."""
2 
3 from contextlib import suppress
4 from functools import partial
5 import logging
6 from typing import Generic
7 
8 import broadlink as blk
9 from broadlink.exceptions import (
10  AuthenticationError,
11  AuthorizationError,
12  BroadlinkException,
13  ConnectionClosedError,
14  NetworkTimeoutError,
15 )
16 from typing_extensions import TypeVar
17 
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.const import (
20  CONF_HOST,
21  CONF_MAC,
22  CONF_NAME,
23  CONF_TIMEOUT,
24  CONF_TYPE,
25  Platform,
26 )
27 from homeassistant.core import CALLBACK_TYPE, HomeAssistant
28 from homeassistant.exceptions import ConfigEntryNotReady
29 from homeassistant.helpers import device_registry as dr
30 
31 from .const import DEFAULT_PORT, DOMAIN, DOMAINS_AND_TYPES
32 from .updater import BroadlinkUpdateManager, get_update_manager
33 
34 _ApiT = TypeVar("_ApiT", bound=blk.Device, default=blk.Device)
35 
36 _LOGGER = logging.getLogger(__name__)
37 
38 
39 def get_domains(device_type: str) -> set[Platform]:
40  """Return the domains available for a device type."""
41  return {d for d, t in DOMAINS_AND_TYPES.items() if device_type in t}
42 
43 
44 class BroadlinkDevice(Generic[_ApiT]):
45  """Manages a Broadlink device."""
46 
47  api: _ApiT
48 
49  def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
50  """Initialize the device."""
51  self.hasshass = hass
52  self.configconfig = config
53  self.update_managerupdate_manager: BroadlinkUpdateManager[_ApiT] | None = None
54  self.fw_versionfw_version: int | None = None
55  self.authorizedauthorized: bool | None = None
56  self.reset_jobs: list[CALLBACK_TYPE] = []
57 
58  @property
59  def name(self) -> str:
60  """Return the name of the device."""
61  return self.configconfig.title
62 
63  @property
64  def unique_id(self) -> str | None:
65  """Return the unique id of the device."""
66  return self.configconfig.unique_id
67 
68  @property
69  def mac_address(self) -> str:
70  """Return the mac address of the device."""
71  return self.configconfig.data[CONF_MAC]
72 
73  @property
74  def available(self) -> bool | None:
75  """Return True if the device is available."""
76  if self.update_managerupdate_manager is None:
77  return False
78  return self.update_managerupdate_manager.available
79 
80  @staticmethod
81  async def async_update(hass: HomeAssistant, entry: ConfigEntry) -> None:
82  """Update the device and related entities.
83 
84  Triggered when the device is renamed on the frontend.
85  """
86  device_registry = dr.async_get(hass)
87  assert entry.unique_id
88  device_entry = device_registry.async_get_device(
89  identifiers={(DOMAIN, entry.unique_id)}
90  )
91  assert device_entry
92  device_registry.async_update_device(device_entry.id, name=entry.title)
93  await hass.config_entries.async_reload(entry.entry_id)
94 
95  def _get_firmware_version(self) -> int | None:
96  """Get firmware version."""
97  self.apiapi.auth()
98  with suppress(BroadlinkException, OSError):
99  return self.apiapi.get_fwversion()
100  return None
101 
102  async def async_setup(self) -> bool:
103  """Set up the device and related entities."""
104  config = self.configconfig
105 
106  api = blk.gendevice(
107  config.data[CONF_TYPE],
108  (config.data[CONF_HOST], DEFAULT_PORT),
109  bytes.fromhex(config.data[CONF_MAC]),
110  name=config.title,
111  )
112  api.timeout = config.data[CONF_TIMEOUT]
113  self.apiapi = api
114 
115  try:
116  self.fw_versionfw_version = await self.hasshass.async_add_executor_job(
117  self._get_firmware_version_get_firmware_version
118  )
119 
120  except AuthenticationError:
121  await self._async_handle_auth_error_async_handle_auth_error()
122  return False
123 
124  except (NetworkTimeoutError, OSError) as err:
125  raise ConfigEntryNotReady from err
126 
127  except BroadlinkException as err:
128  _LOGGER.error(
129  "Failed to authenticate to the device at %s: %s", api.host[0], err
130  )
131  return False
132 
133  self.authorizedauthorized = True
134 
135  update_manager = get_update_manager(self)
136  coordinator = update_manager.coordinator
137  await coordinator.async_config_entry_first_refresh()
138 
139  self.update_managerupdate_manager = update_manager
140  self.hasshass.data[DOMAIN].devices[config.entry_id] = self
141  self.reset_jobs.append(config.add_update_listener(self.async_updateasync_update))
142 
143  # Forward entry setup to related domains.
144  await self.hasshass.config_entries.async_forward_entry_setups(
145  config, get_domains(self.apiapi.type)
146  )
147 
148  return True
149 
150  async def async_unload(self) -> bool:
151  """Unload the device and related entities."""
152  if self.update_managerupdate_manager is None:
153  return True
154 
155  while self.reset_jobs:
156  self.reset_jobs.pop()()
157 
158  return await self.hasshass.config_entries.async_unload_platforms(
159  self.configconfig, get_domains(self.apiapi.type)
160  )
161 
162  async def async_auth(self) -> bool:
163  """Authenticate to the device."""
164  try:
165  await self.hasshass.async_add_executor_job(self.apiapi.auth)
166  except (BroadlinkException, OSError) as err:
167  _LOGGER.debug(
168  "Failed to authenticate to the device at %s: %s", self.apiapi.host[0], err
169  )
170  if isinstance(err, AuthenticationError):
171  await self._async_handle_auth_error_async_handle_auth_error()
172  return False
173  return True
174 
175  async def async_request(self, function, *args, **kwargs):
176  """Send a request to the device."""
177  request = partial(function, *args, **kwargs)
178  try:
179  return await self.hasshass.async_add_executor_job(request)
180  except (AuthorizationError, ConnectionClosedError):
181  if not await self.async_authasync_auth():
182  raise
183  return await self.hasshass.async_add_executor_job(request)
184 
185  async def _async_handle_auth_error(self) -> None:
186  """Handle an authentication error."""
187  if self.authorizedauthorized is False:
188  return
189 
190  self.authorizedauthorized = False
191 
192  _LOGGER.error(
193  (
194  "%s (%s at %s) is locked. Click Configuration in the sidebar, "
195  "click Integrations, click Configure on the device and follow "
196  "the instructions to unlock it"
197  ),
198  self.namename,
199  self.apiapi.model,
200  self.apiapi.host[0],
201  )
202 
203  self.configconfig.async_start_reauth(self.hasshass, data={CONF_NAME: self.namename})