Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Yale Access Bluetooth integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import logging
7 from typing import Any, Self
8 
9 from bleak_retry_connector import BleakError, BLEDevice
10 import voluptuous as vol
11 from yalexs_ble import (
12  AuthError,
13  DisconnectedError,
14  PushLock,
15  ValidatedLockConfig,
16  local_name_is_unique,
17 )
18 from yalexs_ble.const import YALE_MFR_ID
19 
21  BluetoothServiceInfoBleak,
22  async_ble_device_from_address,
23  async_discovered_service_info,
24 )
25 from homeassistant.config_entries import (
26  ConfigEntry,
27  ConfigFlow,
28  ConfigFlowResult,
29  OptionsFlow,
30 )
31 from homeassistant.const import CONF_ADDRESS
32 from homeassistant.core import callback
33 from homeassistant.data_entry_flow import AbortFlow
34 from homeassistant.helpers.typing import DiscoveryInfoType
35 
36 from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN
37 from .util import async_find_existing_service_info, human_readable_name
38 
39 _LOGGER = logging.getLogger(__name__)
40 
41 
43  local_name: str, device: BLEDevice, key: str, slot: int
44 ) -> dict[str, str]:
45  """Validate the lock and return errors if any."""
46  if len(key) != 32:
47  return {CONF_KEY: "invalid_key_format"}
48  try:
49  bytes.fromhex(key)
50  except ValueError:
51  return {CONF_KEY: "invalid_key_format"}
52  if not isinstance(slot, int) or not 0 <= slot <= 255:
53  return {CONF_SLOT: "invalid_key_index"}
54  try:
55  await PushLock(local_name, device.address, device, key, slot).validate()
56  except (DisconnectedError, AuthError, ValueError):
57  return {CONF_KEY: "invalid_auth"}
58  except BleakError:
59  return {"base": "cannot_connect"}
60  except Exception:
61  _LOGGER.exception("Unexpected error")
62  return {"base": "unknown"}
63  return {}
64 
65 
66 class YalexsConfigFlow(ConfigFlow, domain=DOMAIN):
67  """Handle a config flow for Yale Access Bluetooth."""
68 
69  VERSION = 1
70 
71  _address: str | None = None
72  _local_name_is_unique = False
73  active = False
74  local_name: str | None = None
75 
76  def __init__(self) -> None:
77  """Initialize the config flow."""
78  self._discovery_info_discovery_info: BluetoothServiceInfoBleak | None = None
79  self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
80  self._lock_cfg_lock_cfg: ValidatedLockConfig | None = None
81 
83  self, discovery_info: BluetoothServiceInfoBleak
84  ) -> ConfigFlowResult:
85  """Handle the bluetooth discovery step."""
86  await self.async_set_unique_idasync_set_unique_id(discovery_info.address)
87  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
88  self.local_namelocal_name = discovery_info.name
89  self._discovery_info_discovery_info = discovery_info
90  self.context["title_placeholders"] = {
91  "name": human_readable_name(
92  None, discovery_info.name, discovery_info.address
93  ),
94  }
95  return await self.async_step_userasync_step_userasync_step_user()
96 
98  self, discovery_info: DiscoveryInfoType
99  ) -> ConfigFlowResult:
100  """Handle a discovered integration."""
101  lock_cfg = ValidatedLockConfig(
102  discovery_info["name"],
103  discovery_info["address"],
104  discovery_info["serial"],
105  discovery_info["key"],
106  discovery_info["slot"],
107  )
108 
109  address = lock_cfg.address
110  self.local_namelocal_name = lock_cfg.local_name
111  self._local_name_is_unique_local_name_is_unique_local_name_is_unique = local_name_is_unique(self.local_namelocal_name)
112 
113  # We do not want to raise on progress as integration_discovery takes
114  # precedence over other discovery flows since we already have the keys.
115  #
116  # After we do discovery we will abort the flows that do not have the keys
117  # below unless the user is already setting them up.
118  await self.async_set_unique_idasync_set_unique_id(address, raise_on_progress=False)
119  new_data = {CONF_KEY: lock_cfg.key, CONF_SLOT: lock_cfg.slot}
120  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates=new_data)
121  for entry in self._async_current_entries_async_current_entries():
122  if (
123  self._local_name_is_unique_local_name_is_unique_local_name_is_unique
124  and entry.data.get(CONF_LOCAL_NAME) == lock_cfg.local_name
125  ):
126  return self.async_update_reload_and_abortasync_update_reload_and_abort(
127  entry, data={**entry.data, **new_data}, reason="already_configured"
128  )
129 
131  self.hass, self.local_namelocal_name, address
132  )
133  if not self._discovery_info_discovery_info:
134  return self.async_abortasync_abortasync_abort(reason="no_devices_found")
135 
136  self._address_address = address
137  if self.hass.config_entries.flow.async_has_matching_flow(self):
138  raise AbortFlow("already_in_progress")
139 
140  self._lock_cfg_lock_cfg = lock_cfg
141  self.context["title_placeholders"] = {
142  "name": human_readable_name(
143  lock_cfg.name, lock_cfg.local_name, self._discovery_info_discovery_info.address
144  )
145  }
146  return await self.async_step_integration_discovery_confirmasync_step_integration_discovery_confirm()
147 
148  def is_matching(self, other_flow: Self) -> bool:
149  """Return True if other_flow is matching this flow."""
150  # Integration discovery should abort other flows unless they
151  # are already in the process of being set up since this discovery
152  # will already have all the keys and the user can simply confirm.
153  if (
154  self._local_name_is_unique_local_name_is_unique_local_name_is_unique and other_flow.local_name == self.local_namelocal_name
155  ) or other_flow.unique_id == self._address_address:
156  if other_flow.active:
157  # The user has already started interacting with this flow
158  # and entered the keys. We abort the discovery flow since
159  # we assume they do not want to use the discovered keys for
160  # some reason.
161  return True
162  self.hass.config_entries.flow.async_abort(other_flow.flow_id)
163 
164  return False
165 
167  self, user_input: dict[str, Any] | None = None
168  ) -> ConfigFlowResult:
169  """Handle a confirmation of discovered integration."""
170  assert self._discovery_info_discovery_info is not None
171  assert self._lock_cfg_lock_cfg is not None
172  if user_input is not None:
173  return self.async_create_entryasync_create_entryasync_create_entry(
174  title=self._lock_cfg_lock_cfg.name,
175  data={
176  CONF_LOCAL_NAME: self._discovery_info_discovery_info.name,
177  CONF_ADDRESS: self._discovery_info_discovery_info.address,
178  CONF_KEY: self._lock_cfg_lock_cfg.key,
179  CONF_SLOT: self._lock_cfg_lock_cfg.slot,
180  },
181  )
182 
183  self._set_confirm_only_set_confirm_only()
184  return self.async_show_formasync_show_formasync_show_form(
185  step_id="integration_discovery_confirm",
186  description_placeholders={
187  "name": self._lock_cfg_lock_cfg.name,
188  "address": self._discovery_info_discovery_info.address,
189  },
190  )
191 
192  async def async_step_reauth(
193  self, entry_data: Mapping[str, Any]
194  ) -> ConfigFlowResult:
195  """Handle configuration by re-auth."""
196  return await self.async_step_reauth_validateasync_step_reauth_validate()
197 
199  self, user_input: dict[str, Any] | None = None
200  ) -> ConfigFlowResult:
201  """Handle reauth and validation."""
202  errors = {}
203  reauth_entry = self._get_reauth_entry_get_reauth_entry()
204  if user_input is not None:
205  if (
207  self.hass, reauth_entry.data[CONF_ADDRESS], True
208  )
209  ) is None:
210  errors = {"base": "no_longer_in_range"}
211  elif not (
212  errors := await async_validate_lock_or_error(
213  reauth_entry.data[CONF_LOCAL_NAME],
214  device,
215  user_input[CONF_KEY],
216  user_input[CONF_SLOT],
217  )
218  ):
219  return self.async_update_reload_and_abortasync_update_reload_and_abort(
220  reauth_entry, data_updates=user_input
221  )
222 
223  return self.async_show_formasync_show_formasync_show_form(
224  step_id="reauth_validate",
225  data_schema=vol.Schema(
226  {vol.Required(CONF_KEY): str, vol.Required(CONF_SLOT): int}
227  ),
228  description_placeholders={
229  "address": reauth_entry.data[CONF_ADDRESS],
230  "title": reauth_entry.title,
231  },
232  errors=errors,
233  )
234 
235  async def async_step_user(
236  self, user_input: dict[str, Any] | None = None
237  ) -> ConfigFlowResult:
238  """Handle the user step to pick discovered device."""
239  errors: dict[str, str] = {}
240 
241  if user_input is not None:
242  self.activeactiveactive = True
243  address = user_input[CONF_ADDRESS]
244  discovery_info = self._discovered_devices[address]
245  local_name = discovery_info.name
246  key = user_input[CONF_KEY]
247  slot = user_input[CONF_SLOT]
248  await self.async_set_unique_idasync_set_unique_id(
249  discovery_info.address, raise_on_progress=False
250  )
251  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
252  if not (
253  errors := await async_validate_lock_or_error(
254  local_name, discovery_info.device, key, slot
255  )
256  ):
257  return self.async_create_entryasync_create_entryasync_create_entry(
258  title=local_name,
259  data={
260  CONF_LOCAL_NAME: discovery_info.name,
261  CONF_ADDRESS: discovery_info.address,
262  CONF_KEY: key,
263  CONF_SLOT: slot,
264  },
265  )
266 
267  if discovery := self._discovery_info_discovery_info:
268  self._discovered_devices[discovery.address] = discovery
269  else:
270  current_addresses = self._async_current_ids_async_current_ids()
271  current_unique_names = {
272  entry.data.get(CONF_LOCAL_NAME)
273  for entry in self._async_current_entries_async_current_entries()
274  if local_name_is_unique(entry.data.get(CONF_LOCAL_NAME))
275  }
276  for discovery in async_discovered_service_info(self.hass):
277  if (
278  discovery.address in current_addresses
279  or discovery.name in current_unique_names
280  or discovery.address in self._discovered_devices
281  or YALE_MFR_ID not in discovery.manufacturer_data
282  ):
283  continue
284  self._discovered_devices[discovery.address] = discovery
285 
286  if not self._discovered_devices:
287  return self.async_abortasync_abortasync_abort(reason="no_devices_found")
288 
289  data_schema = vol.Schema(
290  {
291  vol.Required(CONF_ADDRESS): vol.In(
292  {
293  service_info.address: (
294  f"{service_info.name} ({service_info.address})"
295  )
296  for service_info in self._discovered_devices.values()
297  }
298  ),
299  vol.Required(CONF_KEY): str,
300  vol.Required(CONF_SLOT): int,
301  }
302  )
303  return self.async_show_formasync_show_formasync_show_form(
304  step_id="user",
305  data_schema=data_schema,
306  errors=errors,
307  )
308 
309  @staticmethod
310  @callback
312  config_entry: ConfigEntry,
313  ) -> YaleXSBLEOptionsFlowHandler:
314  """Get the options flow for this handler."""
316 
317 
319  """Handle YaleXSBLE options."""
320 
321  async def async_step_init(
322  self, user_input: dict[str, Any] | None = None
323  ) -> ConfigFlowResult:
324  """Manage the YaleXSBLE options."""
325  return await self.async_step_device_optionsasync_step_device_options()
326 
328  self, user_input: dict[str, Any] | None = None
329  ) -> ConfigFlowResult:
330  """Manage the YaleXSBLE devices options."""
331  if user_input is not None:
332  return self.async_create_entryasync_create_entry(
333  data={CONF_ALWAYS_CONNECTED: user_input[CONF_ALWAYS_CONNECTED]},
334  )
335 
336  return self.async_show_formasync_show_form(
337  step_id="device_options",
338  data_schema=vol.Schema(
339  {
340  vol.Optional(
341  CONF_ALWAYS_CONNECTED,
342  default=self.config_entryconfig_entryconfig_entry.options.get(
343  CONF_ALWAYS_CONNECTED, False
344  ),
345  ): bool,
346  }
347  ),
348  )
ConfigFlowResult async_step_device_options(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:329
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:323
ConfigFlowResult async_step_integration_discovery(self, DiscoveryInfoType discovery_info)
Definition: config_flow.py:99
YaleXSBLEOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:313
ConfigFlowResult async_step_integration_discovery_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:168
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:194
ConfigFlowResult async_step_reauth_validate(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:200
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:237
ConfigFlowResult async_step_bluetooth(self, BluetoothServiceInfoBleak discovery_info)
Definition: config_flow.py:84
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
set[str|None] _async_current_ids(self, bool include_ignore=True)
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)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=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_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_abort(self, *str reason, 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)
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)
BLEDevice|None async_ble_device_from_address(HomeAssistant hass, str address, bool connectable=True)
Definition: api.py:88
Iterable[BluetoothServiceInfoBleak] async_discovered_service_info(HomeAssistant hass, bool connectable=True)
Definition: api.py:72
str human_readable_name(str hostname, str vendor, str mac_address)
Definition: __init__.py:50
dict[str, Any] validate(SchemaCommonFlowHandler handler, dict[str, Any] user_input)
Definition: config_flow.py:27
dict[str, str] async_validate_lock_or_error(str local_name, BLEDevice device, str key, int slot)
Definition: config_flow.py:44
BluetoothServiceInfoBleak|None async_find_existing_service_info(HomeAssistant hass, str local_name, str address)
Definition: util.py:39