Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Broadlink devices."""
2 
3 from collections.abc import Mapping
4 import errno
5 from functools import partial
6 import logging
7 import socket
8 from typing import Any
9 
10 import broadlink as blk
11 from broadlink.exceptions import (
12  AuthenticationError,
13  BroadlinkException,
14  NetworkTimeoutError,
15 )
16 import voluptuous as vol
17 
18 from homeassistant.components import dhcp
19 from homeassistant.config_entries import (
20  SOURCE_IMPORT,
21  SOURCE_REAUTH,
22  ConfigFlow,
23  ConfigFlowResult,
24 )
25 from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE
26 from homeassistant.data_entry_flow import AbortFlow
27 from homeassistant.helpers import config_validation as cv
28 
29 from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN
30 from .helpers import format_mac
31 
32 _LOGGER = logging.getLogger(__name__)
33 
34 
35 class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
36  """Handle a Broadlink config flow."""
37 
38  VERSION = 1
39 
40  device: blk.Device
41 
42  async def async_set_device(
43  self, device: blk.Device, raise_on_progress: bool = True
44  ) -> None:
45  """Define a device for the config flow."""
46  if device.type not in DEVICE_TYPES:
47  _LOGGER.error(
48  (
49  "Unsupported device: %s. If it worked before, please open "
50  "an issue at https://github.com/home-assistant/core/issues"
51  ),
52  hex(device.devtype),
53  )
54  raise AbortFlow("not_supported")
55 
56  await self.async_set_unique_idasync_set_unique_id(
57  device.mac.hex(), raise_on_progress=raise_on_progress
58  )
59  self.devicedevice = device
60 
61  self.context["title_placeholders"] = {
62  "name": device.name,
63  "model": device.model,
64  "host": device.host[0],
65  }
66 
67  async def async_step_dhcp(
68  self, discovery_info: dhcp.DhcpServiceInfo
69  ) -> ConfigFlowResult:
70  """Handle dhcp discovery."""
71  host = discovery_info.ip
72  unique_id = discovery_info.macaddress.lower().replace(":", "")
73  await self.async_set_unique_idasync_set_unique_id(unique_id)
74  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates={CONF_HOST: host})
75 
76  try:
77  device = await self.hass.async_add_executor_job(blk.hello, host)
78 
79  except NetworkTimeoutError:
80  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
81 
82  except OSError as err:
83  if err.errno == errno.ENETUNREACH:
84  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
85  return self.async_abortasync_abortasync_abort(reason="unknown")
86 
87  if device.type not in DEVICE_TYPES:
88  return self.async_abortasync_abortasync_abort(reason="not_supported")
89 
90  await self.async_set_deviceasync_set_device(device)
91  return await self.async_step_authasync_step_auth()
92 
93  async def async_step_user(
94  self, user_input: dict[str, Any] | None = None
95  ) -> ConfigFlowResult:
96  """Handle a flow initiated by the user."""
97  errors = {}
98 
99  if user_input is not None:
100  host = user_input[CONF_HOST]
101  timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
102 
103  try:
104  hello = partial(blk.hello, host, timeout=timeout)
105  device = await self.hass.async_add_executor_job(hello)
106 
107  except NetworkTimeoutError:
108  errors["base"] = "cannot_connect"
109  err_msg = "Device not found"
110 
111  except OSError as err:
112  if err.errno in {errno.EINVAL, socket.EAI_NONAME}:
113  errors["base"] = "invalid_host"
114  err_msg = "Invalid hostname or IP address"
115  elif err.errno == errno.ENETUNREACH:
116  errors["base"] = "cannot_connect"
117  err_msg = str(err)
118  else:
119  errors["base"] = "unknown"
120  err_msg = str(err)
121 
122  else:
123  device.timeout = timeout
124 
125  if self.sourcesourcesourcesource != SOURCE_REAUTH:
126  await self.async_set_deviceasync_set_device(device)
127  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
128  updates={CONF_HOST: device.host[0], CONF_TIMEOUT: timeout}
129  )
130  return await self.async_step_authasync_step_auth()
131 
132  if device.mac == self.devicedevice.mac:
133  await self.async_set_deviceasync_set_device(device, raise_on_progress=False)
134  return await self.async_step_authasync_step_auth()
135 
136  errors["base"] = "invalid_host"
137  err_msg = (
138  "This is not the device you are looking for. The MAC "
139  f"address must be {format_mac(self.device.mac)}"
140  )
141 
142  _LOGGER.error("Failed to connect to the device at %s: %s", host, err_msg)
143 
144  if self.sourcesourcesourcesource == SOURCE_IMPORT:
145  return self.async_abortasync_abortasync_abort(reason=errors["base"])
146 
147  data_schema = {
148  vol.Required(CONF_HOST): str,
149  vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
150  }
151  return self.async_show_formasync_show_formasync_show_form(
152  step_id="user",
153  data_schema=vol.Schema(data_schema),
154  errors=errors,
155  )
156 
157  async def async_step_auth(self) -> ConfigFlowResult:
158  """Authenticate to the device."""
159  device = self.devicedevice
160  errors: dict[str, str] = {}
161 
162  try:
163  await self.hass.async_add_executor_job(device.auth)
164 
165  except AuthenticationError:
166  errors["base"] = "invalid_auth"
167  await self.async_set_unique_idasync_set_unique_id(device.mac.hex())
168  return await self.async_step_resetasync_step_reset(errors=errors)
169 
170  except NetworkTimeoutError as err:
171  errors["base"] = "cannot_connect"
172  err_msg = str(err)
173 
174  except BroadlinkException as err:
175  errors["base"] = "unknown"
176  err_msg = str(err)
177 
178  except OSError as err:
179  if err.errno == errno.ENETUNREACH:
180  errors["base"] = "cannot_connect"
181  err_msg = str(err)
182  else:
183  errors["base"] = "unknown"
184  err_msg = str(err)
185 
186  else:
187  await self.async_set_unique_idasync_set_unique_id(device.mac.hex())
188  if self.sourcesourcesourcesource == SOURCE_IMPORT:
189  _LOGGER.warning(
190  (
191  "%s (%s at %s) is ready to be configured. Click "
192  "Configuration in the sidebar, click Integrations and "
193  "click Configure on the device to complete the setup"
194  ),
195  device.name,
196  device.model,
197  device.host[0],
198  )
199 
200  if device.is_locked:
201  return await self.async_step_unlockasync_step_unlock()
202  return await self.async_step_finishasync_step_finish()
203 
204  await self.async_set_unique_idasync_set_unique_id(device.mac.hex())
205  _LOGGER.error(
206  "Failed to authenticate to the device at %s: %s", device.host[0], err_msg
207  )
208  return self.async_show_formasync_show_formasync_show_form(step_id="auth", errors=errors)
209 
210  async def async_step_reset(
211  self,
212  user_input: dict[str, Any] | None = None,
213  errors: dict[str, str] | None = None,
214  ) -> ConfigFlowResult:
215  """Guide the user to unlock the device manually.
216 
217  We are unable to authenticate because the device is locked.
218  The user needs to open the Broadlink app and unlock the device.
219  """
220  device = self.devicedevice
221 
222  if user_input is None:
223  return self.async_show_formasync_show_formasync_show_form(
224  step_id="reset",
225  errors=errors,
226  description_placeholders={
227  "name": device.name,
228  "model": device.model,
229  "host": device.host[0],
230  },
231  )
232 
233  return await self.async_step_userasync_step_userasync_step_user(
234  {CONF_HOST: device.host[0], CONF_TIMEOUT: device.timeout}
235  )
236 
237  async def async_step_unlock(
238  self, user_input: dict[str, Any] | None = None
239  ) -> ConfigFlowResult:
240  """Unlock the device.
241 
242  The authentication succeeded, but the device is locked.
243  We can offer an unlock to prevent authorization errors.
244  """
245  device = self.devicedevice
246  errors = {}
247 
248  if user_input is None:
249  pass
250 
251  elif user_input["unlock"]:
252  try:
253  await self.hass.async_add_executor_job(device.set_lock, False)
254 
255  except NetworkTimeoutError as err:
256  errors["base"] = "cannot_connect"
257  err_msg = str(err)
258 
259  except BroadlinkException as err:
260  errors["base"] = "unknown"
261  err_msg = str(err)
262 
263  except OSError as err:
264  if err.errno == errno.ENETUNREACH:
265  errors["base"] = "cannot_connect"
266  err_msg = str(err)
267  else:
268  errors["base"] = "unknown"
269  err_msg = str(err)
270 
271  else:
272  return await self.async_step_finishasync_step_finish()
273 
274  _LOGGER.error(
275  "Failed to unlock the device at %s: %s", device.host[0], err_msg
276  )
277 
278  else:
279  return await self.async_step_finishasync_step_finish()
280 
281  data_schema = {vol.Required("unlock", default=False): bool}
282  return self.async_show_formasync_show_formasync_show_form(
283  step_id="unlock",
284  errors=errors,
285  data_schema=vol.Schema(data_schema),
286  description_placeholders={
287  "name": device.name,
288  "model": device.model,
289  "host": device.host[0],
290  },
291  )
292 
293  async def async_step_finish(
294  self, user_input: dict[str, Any] | None = None
295  ) -> ConfigFlowResult:
296  """Choose a name for the device and create config entry."""
297  device = self.devicedevice
298  errors: dict[str, str] = {}
299 
300  # Abort reauthentication flow.
301  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
302  updates={CONF_HOST: device.host[0], CONF_TIMEOUT: device.timeout}
303  )
304 
305  if user_input is not None:
306  return self.async_create_entryasync_create_entryasync_create_entry(
307  title=user_input[CONF_NAME],
308  data={
309  CONF_HOST: device.host[0],
310  CONF_MAC: device.mac.hex(),
311  CONF_TYPE: device.devtype,
312  CONF_TIMEOUT: device.timeout,
313  },
314  )
315 
316  data_schema = {vol.Required(CONF_NAME, default=device.name): str}
317  return self.async_show_formasync_show_formasync_show_form(
318  step_id="finish", data_schema=vol.Schema(data_schema), errors=errors
319  )
320 
321  async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
322  """Import a device."""
323  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
324  return await self.async_step_userasync_step_userasync_step_user(import_data)
325 
326  async def async_step_reauth(
327  self, entry_data: Mapping[str, Any]
328  ) -> ConfigFlowResult:
329  """Reauthenticate to the device."""
330  device = blk.gendevice(
331  entry_data[CONF_TYPE],
332  (entry_data[CONF_HOST], DEFAULT_PORT),
333  bytes.fromhex(entry_data[CONF_MAC]),
334  name=entry_data[CONF_NAME],
335  )
336  device.timeout = entry_data[CONF_TIMEOUT]
337  await self.async_set_deviceasync_set_device(device)
338  return await self.async_step_resetasync_step_reset()
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
None _async_abort_entries_match(self, dict[str, Any]|None match_dict=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
str
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
str|None source(self)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)