Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Shelly integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from typing import Any, Final
7 
8 from aioshelly.block_device import BlockDevice
9 from aioshelly.common import ConnectionOptions, get_info
10 from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
11 from aioshelly.exceptions import (
12  CustomPortNotSupported,
13  DeviceConnectionError,
14  InvalidAuthError,
15  MacAddressMismatchError,
16 )
17 from aioshelly.rpc_device import RpcDevice
18 import voluptuous as vol
19 
20 from homeassistant.components.zeroconf import ZeroconfServiceInfo
21 from homeassistant.config_entries import (
22  ConfigEntry,
23  ConfigFlow,
24  ConfigFlowResult,
25  OptionsFlow,
26 )
27 from homeassistant.const import (
28  CONF_HOST,
29  CONF_MAC,
30  CONF_PASSWORD,
31  CONF_PORT,
32  CONF_USERNAME,
33 )
34 from homeassistant.core import HomeAssistant, callback
35 from homeassistant.helpers.aiohttp_client import async_get_clientsession
36 from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
37 
38 from .const import (
39  CONF_BLE_SCANNER_MODE,
40  CONF_GEN,
41  CONF_SLEEP_PERIOD,
42  DOMAIN,
43  LOGGER,
44  MODEL_WALL_DISPLAY,
45  BLEScannerMode,
46 )
47 from .coordinator import async_reconnect_soon
48 from .utils import (
49  get_block_device_sleep_period,
50  get_coap_context,
51  get_device_entry_gen,
52  get_http_port,
53  get_info_auth,
54  get_info_gen,
55  get_model_name,
56  get_rpc_device_wakeup_period,
57  get_ws_context,
58  mac_address_from_name,
59 )
60 
61 CONFIG_SCHEMA: Final = vol.Schema(
62  {
63  vol.Required(CONF_HOST): str,
64  vol.Required(CONF_PORT, default=DEFAULT_HTTP_PORT): vol.Coerce(int),
65  }
66 )
67 
68 
69 BLE_SCANNER_OPTIONS = [
70  BLEScannerMode.DISABLED,
71  BLEScannerMode.ACTIVE,
72  BLEScannerMode.PASSIVE,
73 ]
74 
75 INTERNAL_WIFI_AP_IP = "192.168.33.1"
76 
77 
78 async def validate_input(
79  hass: HomeAssistant,
80  host: str,
81  port: int,
82  info: dict[str, Any],
83  data: dict[str, Any],
84 ) -> dict[str, Any]:
85  """Validate the user input allows us to connect.
86 
87  Data has the keys from CONFIG_SCHEMA with values provided by the user.
88  """
89  options = ConnectionOptions(
90  ip_address=host,
91  username=data.get(CONF_USERNAME),
92  password=data.get(CONF_PASSWORD),
93  device_mac=info[CONF_MAC],
94  port=port,
95  )
96 
97  gen = get_info_gen(info)
98 
99  if gen in RPC_GENERATIONS:
100  ws_context = await get_ws_context(hass)
101  rpc_device = await RpcDevice.create(
103  ws_context,
104  options,
105  )
106  try:
107  await rpc_device.initialize()
108  sleep_period = get_rpc_device_wakeup_period(rpc_device.status)
109  finally:
110  await rpc_device.shutdown()
111 
112  return {
113  "title": rpc_device.name,
114  CONF_SLEEP_PERIOD: sleep_period,
115  "model": rpc_device.shelly.get("model"),
116  CONF_GEN: gen,
117  }
118 
119  # Gen1
120  coap_context = await get_coap_context(hass)
121  block_device = await BlockDevice.create(
123  coap_context,
124  options,
125  )
126  try:
127  await block_device.initialize()
128  sleep_period = get_block_device_sleep_period(block_device.settings)
129  finally:
130  await block_device.shutdown()
131 
132  return {
133  "title": block_device.name,
134  CONF_SLEEP_PERIOD: sleep_period,
135  "model": block_device.model,
136  CONF_GEN: gen,
137  }
138 
139 
140 class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
141  """Handle a config flow for Shelly."""
142 
143  VERSION = 1
144  MINOR_VERSION = 2
145 
146  host: str = ""
147  port: int = DEFAULT_HTTP_PORT
148  info: dict[str, Any] = {}
149  device_info: dict[str, Any] = {}
150 
151  async def async_step_user(
152  self, user_input: dict[str, Any] | None = None
153  ) -> ConfigFlowResult:
154  """Handle the initial step."""
155  errors: dict[str, str] = {}
156  if user_input is not None:
157  host = user_input[CONF_HOST]
158  port = user_input[CONF_PORT]
159  try:
160  self.infoinfo = await self._async_get_info(host, port)
161  except DeviceConnectionError:
162  errors["base"] = "cannot_connect"
163  except Exception: # noqa: BLE001
164  LOGGER.exception("Unexpected exception")
165  errors["base"] = "unknown"
166  else:
167  await self.async_set_unique_idasync_set_unique_id(self.infoinfo[CONF_MAC])
168  self._abort_if_unique_id_configured_abort_if_unique_id_configured({CONF_HOST: host})
169  self.hosthost = host
170  self.portport = port
171  if get_info_auth(self.infoinfo):
172  return await self.async_step_credentials()
173 
174  try:
175  device_info = await validate_input(
176  self.hass, host, port, self.infoinfo, {}
177  )
178  except DeviceConnectionError:
179  errors["base"] = "cannot_connect"
180  except MacAddressMismatchError:
181  errors["base"] = "mac_address_mismatch"
182  except CustomPortNotSupported:
183  errors["base"] = "custom_port_not_supported"
184  except Exception: # noqa: BLE001
185  LOGGER.exception("Unexpected exception")
186  errors["base"] = "unknown"
187  else:
188  if device_info["model"]:
189  return self.async_create_entryasync_create_entryasync_create_entry(
190  title=device_info["title"],
191  data={
192  CONF_HOST: user_input[CONF_HOST],
193  CONF_PORT: user_input[CONF_PORT],
194  CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
195  "model": device_info["model"],
196  CONF_GEN: device_info[CONF_GEN],
197  },
198  )
199  errors["base"] = "firmware_not_fully_provisioned"
200 
201  return self.async_show_formasync_show_formasync_show_form(
202  step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
203  )
204 
205  async def async_step_credentials(
206  self, user_input: dict[str, Any] | None = None
207  ) -> ConfigFlowResult:
208  """Handle the credentials step."""
209  errors: dict[str, str] = {}
210  if user_input is not None:
211  if get_info_gen(self.infoinfo) in RPC_GENERATIONS:
212  user_input[CONF_USERNAME] = "admin"
213  try:
214  device_info = await validate_input(
215  self.hass, self.hosthost, self.portport, self.infoinfo, user_input
216  )
217  except InvalidAuthError:
218  errors["base"] = "invalid_auth"
219  except DeviceConnectionError:
220  errors["base"] = "cannot_connect"
221  except MacAddressMismatchError:
222  errors["base"] = "mac_address_mismatch"
223  except Exception: # noqa: BLE001
224  LOGGER.exception("Unexpected exception")
225  errors["base"] = "unknown"
226  else:
227  if device_info["model"]:
228  return self.async_create_entryasync_create_entryasync_create_entry(
229  title=device_info["title"],
230  data={
231  **user_input,
232  CONF_HOST: self.hosthost,
233  CONF_PORT: self.portport,
234  CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
235  "model": device_info["model"],
236  CONF_GEN: device_info[CONF_GEN],
237  },
238  )
239  errors["base"] = "firmware_not_fully_provisioned"
240  else:
241  user_input = {}
242 
243  if get_info_gen(self.infoinfo) in RPC_GENERATIONS:
244  schema = {
245  vol.Required(
246  CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
247  ): str,
248  }
249  else:
250  schema = {
251  vol.Required(
252  CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
253  ): str,
254  vol.Required(
255  CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
256  ): str,
257  }
258 
259  return self.async_show_formasync_show_formasync_show_form(
260  step_id="credentials", data_schema=vol.Schema(schema), errors=errors
261  )
262 
263  async def _async_discovered_mac(self, mac: str, host: str) -> None:
264  """Abort and reconnect soon if the device with the mac address is already configured."""
265  if (
266  current_entry := await self.async_set_unique_idasync_set_unique_id(mac)
267  ) and current_entry.data.get(CONF_HOST) == host:
268  LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac)
269  await async_reconnect_soon(self.hass, current_entry)
270  if host == INTERNAL_WIFI_AP_IP:
271  # If the device is broadcasting the internal wifi ap ip
272  # we can't connect to it, so we should not update the
273  # entry with the new host as it will be unreachable
274  #
275  # This is a workaround for a bug in the firmware 0.12 (and older?)
276  # which should be removed once the firmware is fixed
277  # and the old version is no longer in use
278  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
279  else:
280  self._abort_if_unique_id_configured_abort_if_unique_id_configured({CONF_HOST: host})
281 
282  async def async_step_zeroconf(
283  self, discovery_info: ZeroconfServiceInfo
284  ) -> ConfigFlowResult:
285  """Handle zeroconf discovery."""
286  if discovery_info.ip_address.version == 6:
287  return self.async_abortasync_abortasync_abort(reason="ipv6_not_supported")
288  host = discovery_info.host
289  # First try to get the mac address from the name
290  # so we can avoid making another connection to the
291  # device if we already have it configured
292  if mac := mac_address_from_name(discovery_info.name):
293  await self._async_discovered_mac(mac, host)
294 
295  try:
296  # Devices behind range extender doesn't generate zeroconf packets
297  # so port is always the default one
298  self.infoinfo = await self._async_get_info(host, DEFAULT_HTTP_PORT)
299  except DeviceConnectionError:
300  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
301 
302  if not mac:
303  # We could not get the mac address from the name
304  # so need to check here since we just got the info
305  await self._async_discovered_mac(self.infoinfo[CONF_MAC], host)
306 
307  self.hosthost = host
308  self.context.update(
309  {
310  "title_placeholders": {"name": discovery_info.name.split(".")[0]},
311  "configuration_url": f"http://{discovery_info.host}",
312  }
313  )
314 
315  if get_info_auth(self.infoinfo):
316  return await self.async_step_credentials()
317 
318  try:
319  self.device_infodevice_info = await validate_input(
320  self.hass, self.hosthost, self.portport, self.infoinfo, {}
321  )
322  except DeviceConnectionError:
323  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
324 
325  return await self.async_step_confirm_discovery()
326 
327  async def async_step_confirm_discovery(
328  self, user_input: dict[str, Any] | None = None
329  ) -> ConfigFlowResult:
330  """Handle discovery confirm."""
331  errors: dict[str, str] = {}
332 
333  if not self.device_infodevice_info["model"]:
334  errors["base"] = "firmware_not_fully_provisioned"
335  model = "Shelly"
336  else:
337  model = get_model_name(self.infoinfo)
338  if user_input is not None:
339  return self.async_create_entryasync_create_entryasync_create_entry(
340  title=self.device_infodevice_info["title"],
341  data={
342  "host": self.hosthost,
343  CONF_SLEEP_PERIOD: self.device_infodevice_info[CONF_SLEEP_PERIOD],
344  "model": self.device_infodevice_info["model"],
345  CONF_GEN: self.device_infodevice_info[CONF_GEN],
346  },
347  )
348  self._set_confirm_only_set_confirm_only()
349 
350  return self.async_show_formasync_show_formasync_show_form(
351  step_id="confirm_discovery",
352  description_placeholders={
353  "model": model,
354  "host": self.hosthost,
355  },
356  errors=errors,
357  )
358 
359  async def async_step_reauth(
360  self, entry_data: Mapping[str, Any]
361  ) -> ConfigFlowResult:
362  """Handle configuration by re-auth."""
363  return await self.async_step_reauth_confirm()
364 
365  async def async_step_reauth_confirm(
366  self, user_input: dict[str, Any] | None = None
367  ) -> ConfigFlowResult:
368  """Dialog that informs the user that reauth is required."""
369  errors: dict[str, str] = {}
370  reauth_entry = self._get_reauth_entry_get_reauth_entry()
371  host = reauth_entry.data[CONF_HOST]
372  port = get_http_port(reauth_entry.data)
373 
374  if user_input is not None:
375  try:
376  info = await self._async_get_info(host, port)
377  except (DeviceConnectionError, InvalidAuthError):
378  return self.async_abortasync_abortasync_abort(reason="reauth_unsuccessful")
379 
380  if get_device_entry_gen(reauth_entry) != 1:
381  user_input[CONF_USERNAME] = "admin"
382  try:
383  await validate_input(self.hass, host, port, info, user_input)
384  except (DeviceConnectionError, InvalidAuthError):
385  return self.async_abortasync_abortasync_abort(reason="reauth_unsuccessful")
386  except MacAddressMismatchError:
387  return self.async_abortasync_abortasync_abort(reason="mac_address_mismatch")
388 
389  return self.async_update_reload_and_abortasync_update_reload_and_abort(
390  reauth_entry, data_updates=user_input
391  )
392 
393  if get_device_entry_gen(reauth_entry) in BLOCK_GENERATIONS:
394  schema = {
395  vol.Required(CONF_USERNAME): str,
396  vol.Required(CONF_PASSWORD): str,
397  }
398  else:
399  schema = {vol.Required(CONF_PASSWORD): str}
400 
401  return self.async_show_formasync_show_formasync_show_form(
402  step_id="reauth_confirm",
403  data_schema=vol.Schema(schema),
404  errors=errors,
405  )
406 
407  async def async_step_reconfigure(
408  self, user_input: dict[str, Any] | None = None
409  ) -> ConfigFlowResult:
410  """Handle a reconfiguration flow initialized by the user."""
411  errors = {}
412  reconfigure_entry = self._get_reconfigure_entry_get_reconfigure_entry()
413  self.hosthost = reconfigure_entry.data[CONF_HOST]
414  self.portport = reconfigure_entry.data.get(CONF_PORT, DEFAULT_HTTP_PORT)
415 
416  if user_input is not None:
417  host = user_input[CONF_HOST]
418  port = user_input.get(CONF_PORT, DEFAULT_HTTP_PORT)
419  try:
420  info = await self._async_get_info(host, port)
421  except DeviceConnectionError:
422  errors["base"] = "cannot_connect"
423  except CustomPortNotSupported:
424  errors["base"] = "custom_port_not_supported"
425  else:
426  await self.async_set_unique_idasync_set_unique_id(info[CONF_MAC])
427  self._abort_if_unique_id_mismatch_abort_if_unique_id_mismatch(reason="another_device")
428 
429  return self.async_update_reload_and_abortasync_update_reload_and_abort(
430  reconfigure_entry,
431  data_updates={CONF_HOST: host, CONF_PORT: port},
432  )
433 
434  return self.async_show_formasync_show_formasync_show_form(
435  step_id="reconfigure",
436  data_schema=vol.Schema(
437  {
438  vol.Required(CONF_HOST, default=self.hosthost): str,
439  vol.Required(CONF_PORT, default=self.portport): vol.Coerce(int),
440  }
441  ),
442  description_placeholders={"device_name": reconfigure_entry.title},
443  errors=errors,
444  )
445 
446  async def _async_get_info(self, host: str, port: int) -> dict[str, Any]:
447  """Get info from shelly device."""
448  return await get_info(async_get_clientsession(self.hass), host, port=port)
449 
450  @staticmethod
451  @callback
452  def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
453  """Get the options flow for this handler."""
454  return OptionsFlowHandler()
455 
456  @classmethod
457  @callback
458  def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
459  """Return options flow support for this handler."""
460  return (
461  get_device_entry_gen(config_entry) in RPC_GENERATIONS
462  and not config_entry.data.get(CONF_SLEEP_PERIOD)
463  and config_entry.data.get("model") != MODEL_WALL_DISPLAY
464  )
465 
466 
468  """Handle the option flow for shelly."""
469 
470  async def async_step_init(
471  self, user_input: dict[str, Any] | None = None
472  ) -> ConfigFlowResult:
473  """Handle options flow."""
474  if user_input is not None:
475  return self.async_create_entryasync_create_entry(title="", data=user_input)
476 
477  return self.async_show_formasync_show_form(
478  step_id="init",
479  data_schema=vol.Schema(
480  {
481  vol.Required(
482  CONF_BLE_SCANNER_MODE,
483  default=self.config_entryconfig_entryconfig_entry.options.get(
484  CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
485  ),
486  ): SelectSelector(
488  options=BLE_SCANNER_OPTIONS,
489  translation_key=CONF_BLE_SCANNER_MODE,
490  ),
491  ),
492  }
493  ),
494  )
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:472
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:153
bool async_supports_options_flow(cls, ConfigEntry config_entry)
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_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_step_zeroconf(self, ZeroconfServiceInfo discovery_info)
None _abort_if_unique_id_mismatch(self, *str reason="unique_id_mismatch", Mapping[str, str]|None description_placeholders=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)
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
None config_entry(self, ConfigEntry value)
_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)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
dict[str, Any]|None get_info(HomeAssistant hass)
Definition: coordinator.py:69
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
dict[str, Any] validate_input(HomeAssistant hass, str host, int port, dict[str, Any] info, dict[str, Any] data)
Definition: config_flow.py:84
None async_reconnect_soon(HomeAssistant hass, ShellyConfigEntry entry)
Definition: coordinator.py:848
bool get_info_auth(dict[str, Any] info)
Definition: utils.py:298
WsServer get_ws_context(HomeAssistant hass)
Definition: utils.py:274
int get_info_gen(dict[str, Any] info)
Definition: utils.py:303
str get_model_name(dict[str, Any] info)
Definition: utils.py:308
COAP get_coap_context(HomeAssistant hass)
Definition: utils.py:221
int get_rpc_device_wakeup_period(dict[str, Any] status)
Definition: utils.py:293
str|None mac_address_from_name(str name)
Definition: utils.py:448
int get_device_entry_gen(ConfigEntry entry)
Definition: utils.py:353
int get_http_port(MappingProxyType[str, Any] data)
Definition: utils.py:492
int get_block_device_sleep_period(dict[str, Any] settings)
Definition: utils.py:281
tuple[dict[str, str], dict[str, str]|None] _async_get_info(HomeAssistant hass, dict[str, Any] user_input)
Definition: config_flow.py:96
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)