Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Motionblinds Bluetooth integration."""
2 
3 from __future__ import annotations
4 
5 import logging
6 import re
7 from typing import TYPE_CHECKING, Any
8 
9 from bleak.backends.device import BLEDevice
10 from motionblindsble.const import DISPLAY_NAME, SETTING_DISCONNECT_TIME, MotionBlindType
11 import voluptuous as vol
12 
13 from homeassistant.components import bluetooth
14 from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
15 from homeassistant.config_entries import (
16  ConfigEntry,
17  ConfigFlow,
18  ConfigFlowResult,
19  OptionsFlow,
20 )
21 from homeassistant.const import CONF_ADDRESS
22 from homeassistant.core import callback
23 from homeassistant.exceptions import HomeAssistantError
25  SelectSelector,
26  SelectSelectorConfig,
27  SelectSelectorMode,
28 )
29 
30 from .const import (
31  CONF_BLIND_TYPE,
32  CONF_LOCAL_NAME,
33  CONF_MAC_CODE,
34  DOMAIN,
35  ERROR_COULD_NOT_FIND_MOTOR,
36  ERROR_INVALID_MAC_CODE,
37  ERROR_NO_BLUETOOTH_ADAPTER,
38  ERROR_NO_DEVICES_FOUND,
39  OPTION_DISCONNECT_TIME,
40  OPTION_PERMANENT_CONNECTION,
41 )
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_MAC_CODE): str})
46 
47 
48 class FlowHandler(ConfigFlow, domain=DOMAIN):
49  """Handle a config flow for Motionblinds Bluetooth."""
50 
51  _display_name: str
52 
53  def __init__(self) -> None:
54  """Initialize a ConfigFlow."""
55  self._discovery_info_discovery_info: BluetoothServiceInfoBleak | BLEDevice | None = None
56  self._mac_code_mac_code: str | None = None
57  self._blind_type_blind_type: MotionBlindType | None = None
58 
60  self, discovery_info: BluetoothServiceInfoBleak
61  ) -> ConfigFlowResult:
62  """Handle the bluetooth discovery step."""
63  _LOGGER.debug(
64  "Discovered Motionblinds bluetooth device: %s", discovery_info.as_dict()
65  )
66  await self.async_set_unique_idasync_set_unique_id(discovery_info.address)
67  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
68 
69  self._discovery_info_discovery_info = discovery_info
70  self._mac_code_mac_code = get_mac_from_local_name(discovery_info.name)
71  self._display_name_display_name = DISPLAY_NAME.format(mac_code=self._mac_code_mac_code)
72  self.context["title_placeholders"] = {"name": self._display_name_display_name}
73 
74  return await self.async_step_confirmasync_step_confirm()
75 
76  async def async_step_user(
77  self, user_input: dict[str, Any] | None = None
78  ) -> ConfigFlowResult:
79  """Handle a flow initialized by the user."""
80  errors: dict[str, str] = {}
81  if user_input is not None:
82  mac_code = user_input[CONF_MAC_CODE]
83  # Discover with BLE
84  try:
85  await self.async_discover_motionblindasync_discover_motionblind(mac_code)
86  except NoBluetoothAdapter:
87  return self.async_abortasync_abortasync_abort(reason=EXCEPTION_MAP[NoBluetoothAdapter])
88  except NoDevicesFound:
89  return self.async_abortasync_abortasync_abort(reason=EXCEPTION_MAP[NoDevicesFound])
90  except tuple(EXCEPTION_MAP.keys()) as e:
91  errors = {"base": EXCEPTION_MAP.get(type(e), str(type(e)))}
92  return self.async_show_formasync_show_formasync_show_form(
93  step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
94  )
95  return await self.async_step_confirmasync_step_confirm()
96 
97  scanner_count = bluetooth.async_scanner_count(self.hass, connectable=True)
98  if not scanner_count:
99  _LOGGER.error("No bluetooth adapter found")
100  return self.async_abortasync_abortasync_abort(reason=EXCEPTION_MAP[NoBluetoothAdapter])
101 
102  return self.async_show_formasync_show_formasync_show_form(
103  step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
104  )
105 
107  self, user_input: dict[str, Any] | None = None
108  ) -> ConfigFlowResult:
109  """Confirm a single device."""
110  if user_input is not None:
111  self._blind_type_blind_type = user_input[CONF_BLIND_TYPE]
112 
113  if TYPE_CHECKING:
114  assert self._discovery_info_discovery_info is not None
115 
116  return self.async_create_entryasync_create_entryasync_create_entry(
117  title=self._display_name_display_name,
118  data={
119  CONF_ADDRESS: self._discovery_info_discovery_info.address,
120  CONF_LOCAL_NAME: self._discovery_info_discovery_info.name,
121  CONF_MAC_CODE: self._mac_code_mac_code,
122  CONF_BLIND_TYPE: self._blind_type_blind_type,
123  },
124  )
125 
126  return self.async_show_formasync_show_formasync_show_form(
127  step_id="confirm",
128  data_schema=vol.Schema(
129  {
130  vol.Required(CONF_BLIND_TYPE): SelectSelector(
132  options=[
133  blind_type.name.lower()
134  for blind_type in MotionBlindType
135  ],
136  translation_key=CONF_BLIND_TYPE,
137  mode=SelectSelectorMode.DROPDOWN,
138  )
139  )
140  }
141  ),
142  description_placeholders={"display_name": self._display_name_display_name},
143  )
144 
145  async def async_discover_motionblind(self, mac_code: str) -> None:
146  """Discover Motionblinds initialized by the user."""
147  if not is_valid_mac(mac_code):
148  _LOGGER.error("Invalid MAC code: %s", mac_code.upper())
149  raise InvalidMACCode
150 
151  scanner_count = bluetooth.async_scanner_count(self.hass, connectable=True)
152  if not scanner_count:
153  _LOGGER.error("No bluetooth adapter found")
154  raise NoBluetoothAdapter
155 
156  bleak_scanner = bluetooth.async_get_scanner(self.hass)
157  devices = await bleak_scanner.discover()
158 
159  if len(devices) == 0:
160  _LOGGER.error("Could not find any bluetooth devices")
161  raise NoDevicesFound
162 
163  motion_device: BLEDevice | None = next(
164  (
165  device
166  for device in devices
167  if device
168  and device.name
169  and f"MOTION_{mac_code.upper()}" in device.name
170  ),
171  None,
172  )
173 
174  if motion_device is None:
175  _LOGGER.error("Could not find a motor with MAC code: %s", mac_code.upper())
176  raise CouldNotFindMotor
177 
178  await self.async_set_unique_idasync_set_unique_id(motion_device.address, raise_on_progress=False)
179  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
180 
181  self._discovery_info_discovery_info = motion_device
182  self._mac_code_mac_code = mac_code.upper()
183  self._display_name_display_name = DISPLAY_NAME.format(mac_code=self._mac_code_mac_code)
184 
185  @staticmethod
186  @callback
188  config_entry: ConfigEntry,
189  ) -> OptionsFlow:
190  """Create the options flow."""
191  return OptionsFlowHandler()
192 
193 
195  """Handle an options flow for Motionblinds BLE."""
196 
197  async def async_step_init(
198  self, user_input: dict[str, Any] | None = None
199  ) -> ConfigFlowResult:
200  """Manage the options."""
201  if user_input is not None:
202  return self.async_create_entryasync_create_entry(title="", data=user_input)
203 
204  return self.async_show_formasync_show_form(
205  step_id="init",
206  data_schema=vol.Schema(
207  {
208  vol.Required(
209  OPTION_PERMANENT_CONNECTION,
210  default=(
211  self.config_entryconfig_entryconfig_entry.options.get(
212  OPTION_PERMANENT_CONNECTION, False
213  )
214  ),
215  ): bool,
216  vol.Optional(
217  OPTION_DISCONNECT_TIME,
218  default=(
219  self.config_entryconfig_entryconfig_entry.options.get(
220  OPTION_DISCONNECT_TIME, SETTING_DISCONNECT_TIME
221  )
222  ),
223  ): vol.All(vol.Coerce(int), vol.Range(min=0)),
224  }
225  ),
226  )
227 
228 
229 def is_valid_mac(data: str) -> bool:
230  """Validate the provided MAC address."""
231 
232  mac_regex = r"^[0-9A-Fa-f]{4}$"
233  return bool(re.match(mac_regex, data))
234 
235 
236 def get_mac_from_local_name(data: str) -> str | None:
237  """Get the MAC address from the bluetooth local name."""
238 
239  mac_regex = r"^MOTION_([0-9A-Fa-f]{4})$"
240  match = re.search(mac_regex, data)
241  return str(match.group(1)) if match else None
242 
243 
245  """Error to indicate no motor with that MAC code could be found."""
246 
247 
248 class InvalidMACCode(HomeAssistantError):
249  """Error to indicate the MAC code is invalid."""
250 
251 
253  """Error to indicate no bluetooth adapter could be found."""
254 
255 
257  """Error to indicate no bluetooth devices could be found."""
258 
259 
260 EXCEPTION_MAP = {
261  NoBluetoothAdapter: ERROR_NO_BLUETOOTH_ADAPTER,
262  NoDevicesFound: ERROR_NO_DEVICES_FOUND,
263  CouldNotFindMotor: ERROR_COULD_NOT_FIND_MOTOR,
264  InvalidMACCode: ERROR_INVALID_MAC_CODE,
265 }
ConfigFlowResult async_step_bluetooth(self, BluetoothServiceInfoBleak discovery_info)
Definition: config_flow.py:61
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:78
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:189
ConfigFlowResult async_step_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:108
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:199
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_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)
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)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)