Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow to configure Xiaomi Miio."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import logging
7 from re import search
8 from typing import Any
9 
10 from micloud import MiCloud
11 from micloud.micloudexception import MiCloudAccessDenied
12 import voluptuous as vol
13 
14 from homeassistant.components import zeroconf
15 from homeassistant.config_entries import (
16  ConfigEntry,
17  ConfigFlow,
18  ConfigFlowResult,
19  OptionsFlow,
20 )
21 from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN
22 from homeassistant.core import callback
23 from homeassistant.helpers.device_registry import format_mac
24 
25 from .const import (
26  CONF_CLOUD_COUNTRY,
27  CONF_CLOUD_PASSWORD,
28  CONF_CLOUD_SUBDEVICES,
29  CONF_CLOUD_USERNAME,
30  CONF_FLOW_TYPE,
31  CONF_GATEWAY,
32  CONF_MANUAL,
33  DEFAULT_CLOUD_COUNTRY,
34  DOMAIN,
35  MODELS_ALL,
36  MODELS_ALL_DEVICES,
37  MODELS_GATEWAY,
38  SERVER_COUNTRY_CODES,
39  AuthException,
40  SetupException,
41 )
42 from .device import ConnectXiaomiDevice
43 
44 _LOGGER = logging.getLogger(__name__)
45 
46 DEVICE_SETTINGS = {
47  vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)),
48 }
49 DEVICE_CONFIG = vol.Schema({vol.Required(CONF_HOST): str}).extend(DEVICE_SETTINGS)
50 DEVICE_MODEL_CONFIG = vol.Schema({vol.Required(CONF_MODEL): vol.In(MODELS_ALL)})
51 DEVICE_CLOUD_CONFIG = vol.Schema(
52  {
53  vol.Optional(CONF_CLOUD_USERNAME): str,
54  vol.Optional(CONF_CLOUD_PASSWORD): str,
55  vol.Optional(CONF_CLOUD_COUNTRY, default=DEFAULT_CLOUD_COUNTRY): vol.In(
56  SERVER_COUNTRY_CODES
57  ),
58  vol.Optional(CONF_MANUAL, default=False): bool,
59  }
60 )
61 
62 
64  """Options for the component."""
65 
66  async def async_step_init(
67  self, user_input: dict[str, Any] | None = None
68  ) -> ConfigFlowResult:
69  """Manage the options."""
70  errors = {}
71  if user_input is not None:
72  use_cloud = user_input.get(CONF_CLOUD_SUBDEVICES, False)
73  cloud_username = self.config_entryconfig_entryconfig_entry.data.get(CONF_CLOUD_USERNAME)
74  cloud_password = self.config_entryconfig_entryconfig_entry.data.get(CONF_CLOUD_PASSWORD)
75  cloud_country = self.config_entryconfig_entryconfig_entry.data.get(CONF_CLOUD_COUNTRY)
76 
77  if use_cloud and (
78  not cloud_username or not cloud_password or not cloud_country
79  ):
80  errors["base"] = "cloud_credentials_incomplete"
81  self.config_entryconfig_entryconfig_entry.async_start_reauth(self.hass)
82 
83  if not errors:
84  return self.async_create_entryasync_create_entry(title="", data=user_input)
85 
86  settings_schema = vol.Schema(
87  {
88  vol.Optional(
89  CONF_CLOUD_SUBDEVICES,
90  default=self.config_entryconfig_entryconfig_entry.options.get(CONF_CLOUD_SUBDEVICES, False),
91  ): bool
92  }
93  )
94 
95  return self.async_show_formasync_show_form(
96  step_id="init", data_schema=settings_schema, errors=errors
97  )
98 
99 
100 class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN):
101  """Handle a Xiaomi Miio config flow."""
102 
103  VERSION = 1
104 
105  def __init__(self) -> None:
106  """Initialize."""
107  self.hosthost: str | None = None
108  self.macmac: str | None = None
109  self.tokentoken = None
110  self.modelmodel = None
111  self.namename = None
112  self.cloud_usernamecloud_username = None
113  self.cloud_passwordcloud_password = None
114  self.cloud_countrycloud_country = None
115  self.cloud_devicescloud_devices: dict[str, dict[str, Any]] = {}
116 
117  @staticmethod
118  @callback
119  def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
120  """Get the options flow."""
121  return OptionsFlowHandler()
122 
123  async def async_step_reauth(
124  self, entry_data: Mapping[str, Any]
125  ) -> ConfigFlowResult:
126  """Perform reauth upon an authentication error or missing cloud credentials."""
127  self.hosthost = entry_data[CONF_HOST]
128  self.tokentoken = entry_data[CONF_TOKEN]
129  self.macmac = entry_data[CONF_MAC]
130  self.modelmodel = entry_data.get(CONF_MODEL)
131  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
132 
134  self, user_input: dict[str, Any] | None = None
135  ) -> ConfigFlowResult:
136  """Dialog that informs the user that reauth is required."""
137  if user_input is not None:
138  return await self.async_step_cloudasync_step_cloud()
139  return self.async_show_formasync_show_formasync_show_form(step_id="reauth_confirm")
140 
141  async def async_step_user(
142  self, user_input: dict[str, Any] | None = None
143  ) -> ConfigFlowResult:
144  """Handle a flow initialized by the user."""
145  return await self.async_step_cloudasync_step_cloud()
146 
148  self, discovery_info: zeroconf.ZeroconfServiceInfo
149  ) -> ConfigFlowResult:
150  """Handle zeroconf discovery."""
151  name = discovery_info.name
152  self.hosthost = discovery_info.host
153  self.macmac = discovery_info.properties.get("mac")
154  if self.macmac is None:
155  poch = discovery_info.properties.get("poch", "")
156  if (result := search(r"mac=\w+", poch)) is not None:
157  self.macmac = result.group(0).split("=")[1]
158 
159  if not name or not self.hosthost or not self.macmac:
160  return self.async_abortasync_abortasync_abort(reason="not_xiaomi_miio")
161 
162  self.macmac = format_mac(self.macmac)
163 
164  # Check which device is discovered.
165  for gateway_model in MODELS_GATEWAY:
166  if name.startswith(gateway_model.replace(".", "-")):
167  unique_id = self.macmac
168  await self.async_set_unique_idasync_set_unique_id(unique_id)
169  self._abort_if_unique_id_configured_abort_if_unique_id_configured({CONF_HOST: self.hosthost})
170 
171  self.context.update(
172  {"title_placeholders": {"name": f"Gateway {self.host}"}}
173  )
174 
175  return await self.async_step_cloudasync_step_cloud()
176 
177  for device_model in MODELS_ALL_DEVICES:
178  if name.startswith(device_model.replace(".", "-")):
179  unique_id = self.macmac
180  await self.async_set_unique_idasync_set_unique_id(unique_id)
181  self._abort_if_unique_id_configured_abort_if_unique_id_configured({CONF_HOST: self.hosthost})
182 
183  self.context.update(
184  {"title_placeholders": {"name": f"{device_model} {self.host}"}}
185  )
186 
187  return await self.async_step_cloudasync_step_cloud()
188 
189  # Discovered device is not yet supported
190  _LOGGER.debug(
191  "Not yet supported Xiaomi Miio device '%s' discovered with host %s",
192  name,
193  self.hosthost,
194  )
195  return self.async_abortasync_abortasync_abort(reason="not_xiaomi_miio")
196 
197  def extract_cloud_info(self, cloud_device_info: dict[str, Any]) -> None:
198  """Extract the cloud info."""
199  if self.hosthost is None:
200  self.hosthost = cloud_device_info["localip"]
201  if self.macmac is None:
202  self.macmac = format_mac(cloud_device_info["mac"])
203  if self.modelmodel is None:
204  self.modelmodel = cloud_device_info["model"]
205  if self.namename is None:
206  self.namename = cloud_device_info["name"]
207  self.tokentoken = cloud_device_info["token"]
208 
209  async def async_step_cloud(
210  self, user_input: dict[str, Any] | None = None
211  ) -> ConfigFlowResult:
212  """Configure a xiaomi miio device through the Miio Cloud."""
213  errors = {}
214  if user_input is not None:
215  if user_input[CONF_MANUAL]:
216  return await self.async_step_manualasync_step_manual()
217 
218  cloud_username = user_input.get(CONF_CLOUD_USERNAME)
219  cloud_password = user_input.get(CONF_CLOUD_PASSWORD)
220  cloud_country = user_input.get(CONF_CLOUD_COUNTRY)
221 
222  if not cloud_username or not cloud_password or not cloud_country:
223  errors["base"] = "cloud_credentials_incomplete"
224  return self.async_show_formasync_show_formasync_show_form(
225  step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors
226  )
227 
228  miio_cloud = await self.hass.async_add_executor_job(
229  MiCloud, cloud_username, cloud_password
230  )
231  try:
232  if not await self.hass.async_add_executor_job(miio_cloud.login):
233  errors["base"] = "cloud_login_error"
234  except MiCloudAccessDenied:
235  errors["base"] = "cloud_login_error"
236  except Exception:
237  _LOGGER.exception("Unexpected exception in Miio cloud login")
238  return self.async_abortasync_abortasync_abort(reason="unknown")
239 
240  if errors:
241  return self.async_show_formasync_show_formasync_show_form(
242  step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors
243  )
244 
245  try:
246  devices_raw = await self.hass.async_add_executor_job(
247  miio_cloud.get_devices, cloud_country
248  )
249  except Exception:
250  _LOGGER.exception("Unexpected exception in Miio cloud get devices")
251  return self.async_abortasync_abortasync_abort(reason="unknown")
252 
253  if not devices_raw:
254  errors["base"] = "cloud_no_devices"
255  return self.async_show_formasync_show_formasync_show_form(
256  step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors
257  )
258 
259  self.cloud_devicescloud_devices = {}
260  for device in devices_raw:
261  if not device.get("parent_id"):
262  name = device["name"]
263  model = device["model"]
264  list_name = f"{name} - {model}"
265  self.cloud_devicescloud_devices[list_name] = device
266 
267  self.cloud_usernamecloud_username = cloud_username
268  self.cloud_passwordcloud_password = cloud_password
269  self.cloud_countrycloud_country = cloud_country
270 
271  if self.hosthost is not None:
272  for device in self.cloud_devicescloud_devices.values():
273  cloud_host = device.get("localip")
274  if cloud_host == self.hosthost:
275  self.extract_cloud_infoextract_cloud_info(device)
276  return await self.async_step_connectasync_step_connect()
277 
278  if len(self.cloud_devicescloud_devices) == 1:
279  self.extract_cloud_infoextract_cloud_info(list(self.cloud_devicescloud_devices.values())[0])
280  return await self.async_step_connectasync_step_connect()
281 
282  return await self.async_step_selectasync_step_select()
283 
284  return self.async_show_formasync_show_formasync_show_form(
285  step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors
286  )
287 
288  async def async_step_select(
289  self, user_input: dict[str, Any] | None = None
290  ) -> ConfigFlowResult:
291  """Handle multiple cloud devices found."""
292  errors: dict[str, str] = {}
293  if user_input is not None:
294  cloud_device = self.cloud_devicescloud_devices[user_input["select_device"]]
295  self.extract_cloud_infoextract_cloud_info(cloud_device)
296  return await self.async_step_connectasync_step_connect()
297 
298  select_schema = vol.Schema(
299  {vol.Required("select_device"): vol.In(list(self.cloud_devicescloud_devices))}
300  )
301 
302  return self.async_show_formasync_show_formasync_show_form(
303  step_id="select", data_schema=select_schema, errors=errors
304  )
305 
306  async def async_step_manual(
307  self, user_input: dict[str, Any] | None = None
308  ) -> ConfigFlowResult:
309  """Configure a xiaomi miio device Manually."""
310  errors: dict[str, str] = {}
311  if user_input is not None:
312  self.tokentoken = user_input[CONF_TOKEN]
313  if user_input.get(CONF_HOST):
314  self.hosthost = user_input[CONF_HOST]
315 
316  return await self.async_step_connectasync_step_connect()
317 
318  if self.hosthost:
319  schema = vol.Schema(DEVICE_SETTINGS)
320  else:
321  schema = DEVICE_CONFIG
322 
323  return self.async_show_formasync_show_formasync_show_form(step_id="manual", data_schema=schema, errors=errors)
324 
326  self, user_input: dict[str, Any] | None = None
327  ) -> ConfigFlowResult:
328  """Connect to a xiaomi miio device."""
329  errors: dict[str, str] = {}
330  if self.hosthost is None or self.tokentoken is None:
331  return self.async_abortasync_abortasync_abort(reason="incomplete_info")
332 
333  if user_input is not None:
334  self.modelmodel = user_input[CONF_MODEL]
335 
336  # Try to connect to a Xiaomi Device.
337  connect_device_class = ConnectXiaomiDevice(self.hass)
338  try:
339  await connect_device_class.async_connect_device(self.hosthost, self.tokentoken)
340  except AuthException:
341  if self.modelmodel is None:
342  errors["base"] = "wrong_token"
343  except SetupException:
344  if self.modelmodel is None:
345  errors["base"] = "cannot_connect"
346  except Exception:
347  _LOGGER.exception("Unexpected exception in connect Xiaomi device")
348  return self.async_abortasync_abortasync_abort(reason="unknown")
349 
350  device_info = connect_device_class.device_info
351 
352  if self.modelmodel is None and device_info is not None:
353  self.modelmodel = device_info.model
354 
355  if self.modelmodel is None and not errors:
356  errors["base"] = "cannot_connect"
357 
358  if errors:
359  return self.async_show_formasync_show_formasync_show_form(
360  step_id="connect", data_schema=DEVICE_MODEL_CONFIG, errors=errors
361  )
362 
363  if self.macmac is None and device_info is not None:
364  self.macmac = format_mac(device_info.mac_address)
365 
366  unique_id = self.macmac
367  existing_entry = await self.async_set_unique_idasync_set_unique_id(
368  unique_id, raise_on_progress=False
369  )
370  if existing_entry:
371  data = existing_entry.data.copy()
372  data[CONF_HOST] = self.hosthost
373  data[CONF_TOKEN] = self.tokentoken
374  if (
375  self.cloud_usernamecloud_username is not None
376  and self.cloud_passwordcloud_password is not None
377  and self.cloud_countrycloud_country is not None
378  ):
379  data[CONF_CLOUD_USERNAME] = self.cloud_usernamecloud_username
380  data[CONF_CLOUD_PASSWORD] = self.cloud_passwordcloud_password
381  data[CONF_CLOUD_COUNTRY] = self.cloud_countrycloud_country
382  return self.async_update_reload_and_abortasync_update_reload_and_abort(existing_entry, data=data)
383 
384  if self.namename is None:
385  self.namename = self.modelmodel
386 
387  flow_type = None
388  for gateway_model in MODELS_GATEWAY:
389  if self.modelmodel.startswith(gateway_model):
390  flow_type = CONF_GATEWAY
391 
392  if flow_type is None:
393  for device_model in MODELS_ALL_DEVICES:
394  if self.modelmodel.startswith(device_model):
395  flow_type = CONF_DEVICE
396 
397  if flow_type is not None:
398  return self.async_create_entryasync_create_entryasync_create_entry(
399  title=self.namename,
400  data={
401  CONF_FLOW_TYPE: flow_type,
402  CONF_HOST: self.hosthost,
403  CONF_TOKEN: self.tokentoken,
404  CONF_MODEL: self.modelmodel,
405  CONF_MAC: self.macmac,
406  CONF_CLOUD_USERNAME: self.cloud_usernamecloud_username,
407  CONF_CLOUD_PASSWORD: self.cloud_passwordcloud_password,
408  CONF_CLOUD_COUNTRY: self.cloud_countrycloud_country,
409  },
410  )
411 
412  errors["base"] = "unknown_device"
413  return self.async_show_formasync_show_formasync_show_form(
414  step_id="connect", data_schema=DEVICE_MODEL_CONFIG, errors=errors
415  )
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:68
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:125
ConfigFlowResult async_step_cloud(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:211
None extract_cloud_info(self, dict[str, Any] cloud_device_info)
Definition: config_flow.py:197
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:143
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:149
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:135
ConfigFlowResult async_step_manual(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:308
OptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:119
ConfigFlowResult async_step_select(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:290
ConfigFlowResult async_step_connect(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:327
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_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)
IssData update(pyiss.ISS iss)
Definition: __init__.py:33