1 """Config flow for Improv via BLE integration."""
3 from __future__
import annotations
6 from collections.abc
import Callable, Coroutine
7 from dataclasses
import dataclass
11 from bleak
import BleakError
12 from improv_ble_client
import (
19 errors
as improv_ble_errors,
21 import voluptuous
as vol
29 from .const
import DOMAIN
31 _LOGGER = logging.getLogger(__name__)
33 STEP_PROVISION_SCHEMA = vol.Schema(
35 vol.Required(
"ssid"): str,
36 vol.Optional(
"password"): str,
43 """Container for WiFi credentials."""
50 """Handle a config flow for Improv via BLE."""
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
64 """Initialize the config flow."""
65 self.
_device_device: ImprovBLEClient |
None =
None
67 self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
69 self.
_discovery_info_discovery_info: bluetooth.BluetoothServiceInfoBleak |
None =
None
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] = {}
77 if user_input
is not None:
78 address = user_input[CONF_ADDRESS]
87 for discovery
in bluetooth.async_discovered_service_info(self.hass):
89 discovery.address
in current_addresses
90 or discovery.address
in self._discovered_devices
91 or not device_filter(discovery.advertisement)
94 self._discovered_devices[discovery.address] = discovery
96 if not self._discovered_devices:
99 data_schema = vol.Schema(
101 vol.Required(CONF_ADDRESS): vol.In(
103 service_info.address: (
104 f
"{service_info.name} ({service_info.address})"
106 for service_info
in self._discovered_devices.values()
113 data_schema=data_schema,
118 """Check improv state and abort flow if needed."""
124 improv_service_data = ImprovServiceData.from_bytes(
125 service_data[SERVICE_DATA_UUID]
127 except improv_ble_errors.InvalidCommand
as err:
129 "Aborting improv flow, device %s sent invalid improv data: '%s'",
131 service_data[SERVICE_DATA_UUID].hex(),
133 raise AbortFlow(
"invalid_improv_data")
from err
135 if improv_service_data.state
in (State.PROVISIONING, State.PROVISIONED):
137 "Aborting improv flow, device %s is already provisioned: %s",
139 improv_service_data.state,
146 service_info: bluetooth.BluetoothServiceInfoBleak,
147 change: bluetooth.BluetoothChange,
149 """Update from a ble callback."""
151 "Got updated BLE data: %s",
152 service_info.service_data[SERVICE_DATA_UUID].hex(),
159 self.hass.config_entries.flow.async_abort(self.flow_id)
162 """Unregister bluetooth callbacks."""
169 self, discovery_info: bluetooth.BluetoothServiceInfoBleak
170 ) -> ConfigFlowResult:
171 """Handle the Bluetooth discovery step."""
182 {bluetooth.match.ADDRESS: discovery_info.address}
184 bluetooth.BluetoothScanningMode.PASSIVE,
188 self.context[
"title_placeholders"] = {
"name": name}
192 self, user_input: dict[str, Any] |
None =
None
193 ) -> ConfigFlowResult:
194 """Handle bluetooth confirm step."""
198 if user_input
is None:
201 step_id=
"bluetooth_confirm",
202 description_placeholders={
"name": name},
209 self, user_input: dict[str, Any] |
None =
None
210 ) -> ConfigFlowResult:
211 """Start improv flow.
213 If the device supports identification, show a menu, if it does not,
214 ask for WiFi credentials.
225 self.
_can_identify_can_identify = await self._try_call(device.can_identify())
226 except AbortFlow
as err:
233 """Show the main menu."""
243 self, user_input: dict[str, Any] |
None =
None
244 ) -> ConfigFlowResult:
245 """Handle identify step."""
247 assert self.
_device_device
is not None
249 if user_input
is None:
251 await self._try_call(self.
_device_device.identify())
252 except AbortFlow
as err:
258 self, user_input: dict[str, Any] |
None =
None
259 ) -> ConfigFlowResult:
260 """Handle provision step."""
262 assert self.
_device_device
is not None
264 if user_input
is None and self.
_credentials_credentials
is None:
266 step_id=
"provision", data_schema=STEP_PROVISION_SCHEMA
268 if user_input
is not None:
270 user_input.get(
"password",
""), user_input[
"ssid"]
274 need_authorization = await self._try_call(self.
_device_device.need_authorization())
275 except AbortFlow
as err:
277 _LOGGER.debug(
"Need authorization: %s", need_authorization)
278 if need_authorization:
283 self, user_input: dict[str, Any] |
None =
None
284 ) -> ConfigFlowResult:
285 """Execute provisioning."""
287 async
def _do_provision() -> None:
290 assert self.
_device_device
is not None
294 redirect_url = await self._try_call(
299 except AbortFlow
as err:
302 except improv_ble_errors.ProvisioningFailed
as err:
303 if err.error == Error.NOT_AUTHORIZED:
304 _LOGGER.debug(
"Need authorization when calling provision")
307 if err.error == Error.UNABLE_TO_CONNECT:
309 errors[
"base"] =
"unable_to_connect"
314 _LOGGER.debug(
"Provision successful, redirect URL: %s", redirect_url)
317 flow_unique_id = flow[
"context"].
get(
"unique_id")
319 flow[
"flow_id"] != self.flow_id
322 self.hass.config_entries.flow.async_abort(flow[
"flow_id"])
325 reason=
"provision_successful_url",
326 description_placeholders={
"url": redirect_url},
332 step_id=
"provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors
338 _do_provision(), eager_start=
False
343 step_id=
"do_provision",
344 progress_action=
"provisioning",
352 self, user_input: dict[str, Any] |
None =
None
353 ) -> ConfigFlowResult:
354 """Show the result of the provision step."""
363 self, user_input: dict[str, Any] |
None =
None
364 ) -> ConfigFlowResult:
365 """Handle authorize step."""
367 assert self.
_device_device
is not None
369 _LOGGER.debug(
"Wait for authorization")
371 authorized_event = asyncio.Event()
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()
379 self.
_unsub_unsub = await self._try_call(
380 self.
_device_device.subscribe_state_updates(on_state_update)
382 except AbortFlow
as err:
386 authorized_event.wait(), eager_start=
False
392 progress_action=
"authorize",
403 async
def _try_call[_T](func: Coroutine[Any, Any, _T]) -> _T:
404 """Call the library and abort flow on common errors."""
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:
415 except Exception
as err:
416 _LOGGER.exception(
"Unexpected exception")
421 """Notification that the flow has been removed."""
ConfigFlowResult async_step_do_provision(self, dict[str, Any]|None user_input=None)
None _abort_if_provisioned(self)
ConfigFlowResult async_step_bluetooth(self, bluetooth.BluetoothServiceInfoBleak discovery_info)
None _unregister_bluetooth_callback(self)
ConfigFlowResult async_step_provision_done(self, dict[str, Any]|None user_input=None)
None _async_update_ble(self, bluetooth.BluetoothServiceInfoBleak service_info, bluetooth.BluetoothChange change)
ConfigFlowResult async_step_provision(self, dict[str, Any]|None user_input=None)
_remove_bluetooth_callback
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_authorize(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_main_menu(self, None _=None)
ConfigFlowResult async_step_identify(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_start_improv(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_bluetooth_confirm(self, dict[str, Any]|None user_input=None)
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)