Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Improv via BLE integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Coroutine
7 from dataclasses import dataclass
8 import logging
9 from typing import Any
10 
11 from bleak import BleakError
12 from improv_ble_client import (
13  SERVICE_DATA_UUID,
14  Error,
15  ImprovBLEClient,
16  ImprovServiceData,
17  State,
18  device_filter,
19  errors as improv_ble_errors,
20 )
21 import voluptuous as vol
22 
23 from homeassistant.components import bluetooth
24 from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
25 from homeassistant.const import CONF_ADDRESS
26 from homeassistant.core import callback
27 from homeassistant.data_entry_flow import AbortFlow
28 
29 from .const import DOMAIN
30 
31 _LOGGER = logging.getLogger(__name__)
32 
33 STEP_PROVISION_SCHEMA = vol.Schema(
34  {
35  vol.Required("ssid"): str,
36  vol.Optional("password"): str,
37  }
38 )
39 
40 
41 @dataclass
43  """Container for WiFi credentials."""
44 
45  password: str
46  ssid: str
47 
48 
49 class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
50  """Handle a config flow for Improv via BLE."""
51 
52  VERSION = 1
53 
54  _authorize_task: asyncio.Task | None = None
55  _can_identify: bool | None = None
56  _credentials: Credentials | None = None
57  _provision_result: ConfigFlowResult | None = None
58  _provision_task: asyncio.Task | None = None
59  _reauth_entry: ConfigEntry | None = None
60  _remove_bluetooth_callback: Callable[[], None] | None = None
61  _unsub: Callable[[], None] | None = None
62 
63  def __init__(self) -> None:
64  """Initialize the config flow."""
65  self._device_device: ImprovBLEClient | None = None
66  # Populated by user step
67  self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
68  # Populated by bluetooth, reauth_confirm and user steps
69  self._discovery_info_discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
70 
71  async def async_step_user(
72  self, user_input: dict[str, Any] | None = None
73  ) -> ConfigFlowResult:
74  """Handle the user step to pick discovered device."""
75  errors: dict[str, str] = {}
76 
77  if user_input is not None:
78  address = user_input[CONF_ADDRESS]
79  await self.async_set_unique_idasync_set_unique_id(address, raise_on_progress=False)
80  # Guard against the user selecting a device which has been configured by
81  # another flow.
82  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
83  self._discovery_info_discovery_info = self._discovered_devices[address]
84  return await self.async_step_start_improvasync_step_start_improv()
85 
86  current_addresses = self._async_current_ids_async_current_ids()
87  for discovery in bluetooth.async_discovered_service_info(self.hass):
88  if (
89  discovery.address in current_addresses
90  or discovery.address in self._discovered_devices
91  or not device_filter(discovery.advertisement)
92  ):
93  continue
94  self._discovered_devices[discovery.address] = discovery
95 
96  if not self._discovered_devices:
97  return self.async_abortasync_abortasync_abort(reason="no_devices_found")
98 
99  data_schema = vol.Schema(
100  {
101  vol.Required(CONF_ADDRESS): vol.In(
102  {
103  service_info.address: (
104  f"{service_info.name} ({service_info.address})"
105  )
106  for service_info in self._discovered_devices.values()
107  }
108  ),
109  }
110  )
111  return self.async_show_formasync_show_formasync_show_form(
112  step_id="user",
113  data_schema=data_schema,
114  errors=errors,
115  )
116 
117  def _abort_if_provisioned(self) -> None:
118  """Check improv state and abort flow if needed."""
119  # mypy is not aware that we can't get here without having these set already
120  assert self._discovery_info_discovery_info is not None
121 
122  service_data = self._discovery_info_discovery_info.service_data
123  try:
124  improv_service_data = ImprovServiceData.from_bytes(
125  service_data[SERVICE_DATA_UUID]
126  )
127  except improv_ble_errors.InvalidCommand as err:
128  _LOGGER.warning(
129  "Aborting improv flow, device %s sent invalid improv data: '%s'",
130  self._discovery_info_discovery_info.address,
131  service_data[SERVICE_DATA_UUID].hex(),
132  )
133  raise AbortFlow("invalid_improv_data") from err
134 
135  if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED):
136  _LOGGER.debug(
137  "Aborting improv flow, device %s is already provisioned: %s",
138  self._discovery_info_discovery_info.address,
139  improv_service_data.state,
140  )
141  raise AbortFlow("already_provisioned")
142 
143  @callback
145  self,
146  service_info: bluetooth.BluetoothServiceInfoBleak,
147  change: bluetooth.BluetoothChange,
148  ) -> None:
149  """Update from a ble callback."""
150  _LOGGER.debug(
151  "Got updated BLE data: %s",
152  service_info.service_data[SERVICE_DATA_UUID].hex(),
153  )
154 
155  self._discovery_info_discovery_info = service_info
156  try:
157  self._abort_if_provisioned_abort_if_provisioned()
158  except AbortFlow:
159  self.hass.config_entries.flow.async_abort(self.flow_id)
160 
162  """Unregister bluetooth callbacks."""
163  if not self._remove_bluetooth_callback_remove_bluetooth_callback:
164  return
165  self._remove_bluetooth_callback_remove_bluetooth_callback()
166  self._remove_bluetooth_callback_remove_bluetooth_callback = None
167 
169  self, discovery_info: bluetooth.BluetoothServiceInfoBleak
170  ) -> ConfigFlowResult:
171  """Handle the Bluetooth discovery step."""
172  self._discovery_info_discovery_info = discovery_info
173 
174  await self.async_set_unique_idasync_set_unique_id(discovery_info.address)
175  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
176  self._abort_if_provisioned_abort_if_provisioned()
177 
178  self._remove_bluetooth_callback_remove_bluetooth_callback = bluetooth.async_register_callback(
179  self.hass,
180  self._async_update_ble_async_update_ble,
182  {bluetooth.match.ADDRESS: discovery_info.address}
183  ),
184  bluetooth.BluetoothScanningMode.PASSIVE,
185  )
186 
187  name = self._discovery_info_discovery_info.name or self._discovery_info_discovery_info.address
188  self.context["title_placeholders"] = {"name": name}
189  return await self.async_step_bluetooth_confirmasync_step_bluetooth_confirm()
190 
192  self, user_input: dict[str, Any] | None = None
193  ) -> ConfigFlowResult:
194  """Handle bluetooth confirm step."""
195  # mypy is not aware that we can't get here without having these set already
196  assert self._discovery_info_discovery_info is not None
197 
198  if user_input is None:
199  name = self._discovery_info_discovery_info.name or self._discovery_info_discovery_info.address
200  return self.async_show_formasync_show_formasync_show_form(
201  step_id="bluetooth_confirm",
202  description_placeholders={"name": name},
203  )
204 
205  self._unregister_bluetooth_callback_unregister_bluetooth_callback()
206  return await self.async_step_start_improvasync_step_start_improv()
207 
209  self, user_input: dict[str, Any] | None = None
210  ) -> ConfigFlowResult:
211  """Start improv flow.
212 
213  If the device supports identification, show a menu, if it does not,
214  ask for WiFi credentials.
215  """
216  # mypy is not aware that we can't get here without having these set already
217  assert self._discovery_info_discovery_info is not None
218 
219  if not self._device_device:
220  self._device_device = ImprovBLEClient(self._discovery_info_discovery_info.device)
221  device = self._device_device
222 
223  if self._can_identify_can_identify is None:
224  try:
225  self._can_identify_can_identify = await self._try_call(device.can_identify())
226  except AbortFlow as err:
227  return self.async_abortasync_abortasync_abort(reason=err.reason)
228  if self._can_identify_can_identify:
229  return await self.async_step_main_menuasync_step_main_menu()
230  return await self.async_step_provisionasync_step_provision()
231 
232  async def async_step_main_menu(self, _: None = None) -> ConfigFlowResult:
233  """Show the main menu."""
234  return self.async_show_menuasync_show_menu(
235  step_id="main_menu",
236  menu_options=[
237  "identify",
238  "provision",
239  ],
240  )
241 
243  self, user_input: dict[str, Any] | None = None
244  ) -> ConfigFlowResult:
245  """Handle identify step."""
246  # mypy is not aware that we can't get here without having these set already
247  assert self._device_device is not None
248 
249  if user_input is None:
250  try:
251  await self._try_call(self._device_device.identify())
252  except AbortFlow as err:
253  return self.async_abortasync_abortasync_abort(reason=err.reason)
254  return self.async_show_formasync_show_formasync_show_form(step_id="identify")
255  return await self.async_step_start_improvasync_step_start_improv()
256 
258  self, user_input: dict[str, Any] | None = None
259  ) -> ConfigFlowResult:
260  """Handle provision step."""
261  # mypy is not aware that we can't get here without having these set already
262  assert self._device_device is not None
263 
264  if user_input is None and self._credentials_credentials is None:
265  return self.async_show_formasync_show_formasync_show_form(
266  step_id="provision", data_schema=STEP_PROVISION_SCHEMA
267  )
268  if user_input is not None:
269  self._credentials_credentials = Credentials(
270  user_input.get("password", ""), user_input["ssid"]
271  )
272 
273  try:
274  need_authorization = await self._try_call(self._device_device.need_authorization())
275  except AbortFlow as err:
276  return self.async_abortasync_abortasync_abort(reason=err.reason)
277  _LOGGER.debug("Need authorization: %s", need_authorization)
278  if need_authorization:
279  return await self.async_step_authorizeasync_step_authorize()
280  return await self.async_step_do_provisionasync_step_do_provision()
281 
283  self, user_input: dict[str, Any] | None = None
284  ) -> ConfigFlowResult:
285  """Execute provisioning."""
286 
287  async def _do_provision() -> None:
288  # mypy is not aware that we can't get here without having these set already
289  assert self._credentials_credentials is not None
290  assert self._device_device is not None
291 
292  errors = {}
293  try:
294  redirect_url = await self._try_call(
295  self._device_device.provision(
296  self._credentials_credentials.ssid, self._credentials_credentials.password, None
297  )
298  )
299  except AbortFlow as err:
300  self._provision_result_provision_result = self.async_abortasync_abortasync_abort(reason=err.reason)
301  return
302  except improv_ble_errors.ProvisioningFailed as err:
303  if err.error == Error.NOT_AUTHORIZED:
304  _LOGGER.debug("Need authorization when calling provision")
305  self._provision_result_provision_result = await self.async_step_authorizeasync_step_authorize()
306  return
307  if err.error == Error.UNABLE_TO_CONNECT:
308  self._credentials_credentials = None
309  errors["base"] = "unable_to_connect"
310  else:
311  self._provision_result_provision_result = self.async_abortasync_abortasync_abort(reason="unknown")
312  return
313  else:
314  _LOGGER.debug("Provision successful, redirect URL: %s", redirect_url)
315  # Abort all flows in progress with same unique ID
316  for flow in self._async_in_progress_async_in_progress(include_uninitialized=True):
317  flow_unique_id = flow["context"].get("unique_id")
318  if (
319  flow["flow_id"] != self.flow_id
320  and self.unique_idunique_idunique_id == flow_unique_id
321  ):
322  self.hass.config_entries.flow.async_abort(flow["flow_id"])
323  if redirect_url:
324  self._provision_result_provision_result = self.async_abortasync_abortasync_abort(
325  reason="provision_successful_url",
326  description_placeholders={"url": redirect_url},
327  )
328  return
329  self._provision_result_provision_result = self.async_abortasync_abortasync_abort(reason="provision_successful")
330  return
331  self._provision_result_provision_result = self.async_show_formasync_show_formasync_show_form(
332  step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors
333  )
334  return
335 
336  if not self._provision_task_provision_task:
337  self._provision_task_provision_task = self.hass.async_create_task(
338  _do_provision(), eager_start=False
339  )
340 
341  if not self._provision_task_provision_task.done():
342  return self.async_show_progressasync_show_progress(
343  step_id="do_provision",
344  progress_action="provisioning",
345  progress_task=self._provision_task_provision_task,
346  )
347 
348  self._provision_task_provision_task = None
349  return self.async_show_progress_doneasync_show_progress_done(next_step_id="provision_done")
350 
352  self, user_input: dict[str, Any] | None = None
353  ) -> ConfigFlowResult:
354  """Show the result of the provision step."""
355  # mypy is not aware that we can't get here without having these set already
356  assert self._provision_result_provision_result is not None
357 
358  result = self._provision_result_provision_result
359  self._provision_result_provision_result = None
360  return result
361 
363  self, user_input: dict[str, Any] | None = None
364  ) -> ConfigFlowResult:
365  """Handle authorize step."""
366  # mypy is not aware that we can't get here without having these set already
367  assert self._device_device is not None
368 
369  _LOGGER.debug("Wait for authorization")
370  if not self._authorize_task_authorize_task:
371  authorized_event = asyncio.Event()
372 
373  def on_state_update(state: State) -> None:
374  _LOGGER.debug("State update: %s", state.name)
375  if state != State.AUTHORIZATION_REQUIRED:
376  authorized_event.set()
377 
378  try:
379  self._unsub_unsub = await self._try_call(
380  self._device_device.subscribe_state_updates(on_state_update)
381  )
382  except AbortFlow as err:
383  return self.async_abortasync_abortasync_abort(reason=err.reason)
384 
385  self._authorize_task_authorize_task = self.hass.async_create_task(
386  authorized_event.wait(), eager_start=False
387  )
388 
389  if not self._authorize_task_authorize_task.done():
390  return self.async_show_progressasync_show_progress(
391  step_id="authorize",
392  progress_action="authorize",
393  progress_task=self._authorize_task_authorize_task,
394  )
395 
396  self._authorize_task_authorize_task = None
397  if self._unsub_unsub:
398  self._unsub_unsub()
399  self._unsub_unsub = None
400  return self.async_show_progress_doneasync_show_progress_done(next_step_id="provision")
401 
402  @staticmethod
403  async def _try_call[_T](func: Coroutine[Any, Any, _T]) -> _T:
404  """Call the library and abort flow on common errors."""
405  try:
406  return await func
407  except BleakError as err:
408  _LOGGER.warning("BleakError", exc_info=err)
409  raise AbortFlow("cannot_connect") from err
410  except improv_ble_errors.CharacteristicMissingError as err:
411  _LOGGER.warning("CharacteristicMissing", exc_info=err)
412  raise AbortFlow("characteristic_missing") from err
413  except improv_ble_errors.CommandFailed:
414  raise
415  except Exception as err:
416  _LOGGER.exception("Unexpected exception")
417  raise AbortFlow("unknown") from err
418 
419  @callback
420  def async_remove(self) -> None:
421  """Notification that the flow has been removed."""
422  self._unregister_bluetooth_callback_unregister_bluetooth_callback()
ConfigFlowResult async_step_do_provision(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:284
ConfigFlowResult async_step_bluetooth(self, bluetooth.BluetoothServiceInfoBleak discovery_info)
Definition: config_flow.py:170
ConfigFlowResult async_step_provision_done(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:353
None _async_update_ble(self, bluetooth.BluetoothServiceInfoBleak service_info, bluetooth.BluetoothChange change)
Definition: config_flow.py:148
ConfigFlowResult async_step_provision(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:259
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:73
ConfigFlowResult async_step_authorize(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:364
ConfigFlowResult async_step_identify(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:244
ConfigFlowResult async_step_start_improv(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:210
ConfigFlowResult async_step_bluetooth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:193
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)
list[ConfigFlowResult] _async_in_progress(self, bool include_uninitialized=False, dict[str, Any]|None match_context=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)
_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_show_progress(self, *str|None step_id=None, str progress_action, Mapping[str, str]|None description_placeholders=None, asyncio.Task[Any]|None progress_task=None)
_FlowResultT async_show_menu(self, *str|None step_id=None, Container[str] menu_options, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_show_progress_done(self, *str next_step_id)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88