Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow to configure roomba component."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from functools import partial
7 from typing import Any
8 
9 from roombapy import RoombaFactory, RoombaInfo
10 from roombapy.discovery import RoombaDiscovery
11 from roombapy.getpassword import RoombaPassword
12 import voluptuous as vol
13 
14 from homeassistant.components import dhcp, zeroconf
15 from homeassistant.config_entries import (
16  ConfigEntry,
17  ConfigFlow,
18  ConfigFlowResult,
19  OptionsFlow,
20 )
21 from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD
22 from homeassistant.core import HomeAssistant, callback
23 
24 from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout
25 from .const import (
26  CONF_BLID,
27  CONF_CONTINUOUS,
28  DEFAULT_CONTINUOUS,
29  DEFAULT_DELAY,
30  DOMAIN,
31  ROOMBA_SESSION,
32 )
33 
34 ROOMBA_DISCOVERY_LOCK = "roomba_discovery_lock"
35 ALL_ATTEMPTS = 2
36 HOST_ATTEMPTS = 6
37 ROOMBA_WAKE_TIME = 6
38 
39 DEFAULT_OPTIONS = {CONF_CONTINUOUS: DEFAULT_CONTINUOUS, CONF_DELAY: DEFAULT_DELAY}
40 
41 MAX_NUM_DEVICES_TO_DISCOVER = 25
42 
43 AUTH_HELP_URL_KEY = "auth_help_url"
44 AUTH_HELP_URL_VALUE = (
45  "https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials"
46 )
47 
48 
49 async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
50  """Validate the user input allows us to connect.
51 
52  Data has the keys from DATA_SCHEMA with values provided by the user.
53  """
54  roomba = await hass.async_add_executor_job(
55  partial(
56  RoombaFactory.create_roomba,
57  address=data[CONF_HOST],
58  blid=data[CONF_BLID],
59  password=data[CONF_PASSWORD],
60  continuous=True,
61  delay=data[CONF_DELAY],
62  )
63  )
64 
65  info = await async_connect_or_timeout(hass, roomba)
66  if info:
67  await async_disconnect_or_timeout(hass, roomba)
68 
69  return {
70  ROOMBA_SESSION: info[ROOMBA_SESSION],
71  CONF_NAME: info[CONF_NAME],
72  CONF_HOST: data[CONF_HOST],
73  }
74 
75 
76 class RoombaConfigFlow(ConfigFlow, domain=DOMAIN):
77  """Roomba configuration flow."""
78 
79  VERSION = 1
80 
81  name: str | None = None
82  blid: str
83  host: str | None = None
84 
85  def __init__(self) -> None:
86  """Initialize the roomba flow."""
87  self.discovered_robotsdiscovered_robots: dict[str, RoombaInfo] = {}
88 
89  @staticmethod
90  @callback
92  config_entry: ConfigEntry,
93  ) -> RoombaOptionsFlowHandler:
94  """Get the options flow for this handler."""
96 
98  self, discovery_info: zeroconf.ZeroconfServiceInfo
99  ) -> ConfigFlowResult:
100  """Handle zeroconf discovery."""
101  return await self._async_step_discovery_async_step_discovery(
102  discovery_info.host, discovery_info.hostname.lower().removesuffix(".local.")
103  )
104 
105  async def async_step_dhcp(
106  self, discovery_info: dhcp.DhcpServiceInfo
107  ) -> ConfigFlowResult:
108  """Handle dhcp discovery."""
109  return await self._async_step_discovery_async_step_discovery(
110  discovery_info.ip, discovery_info.hostname
111  )
112 
114  self, ip_address: str, hostname: str
115  ) -> ConfigFlowResult:
116  """Handle any discovery."""
117  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: ip_address})
118 
119  if not hostname.startswith(("irobot-", "roomba-")):
120  return self.async_abortasync_abortasync_abort(reason="not_irobot_device")
121 
122  self.hosthost = ip_address
123  self.blidblid = _async_blid_from_hostname(hostname)
124  await self.async_set_unique_idasync_set_unique_id(self.blidblid)
125  self._abort_if_unique_id_configured_abort_if_unique_id_configured(updates={CONF_HOST: ip_address})
126 
127  # Because the hostname is so long some sources may
128  # truncate the hostname since it will be longer than
129  # the valid allowed length. If we already have a flow
130  # going for a longer hostname we abort so the user
131  # does not see two flows if discovery fails.
132  for progress in self._async_in_progress_async_in_progress():
133  flow_unique_id = progress["context"].get("unique_id")
134  if not flow_unique_id:
135  continue
136  if flow_unique_id.startswith(self.blidblid):
137  return self.async_abortasync_abortasync_abort(reason="short_blid")
138  if self.blidblid.startswith(flow_unique_id):
139  self.hass.config_entries.flow.async_abort(progress["flow_id"])
140 
141  self.context["title_placeholders"] = {"host": self.hosthost, "name": self.blidblid}
142  return await self.async_step_userasync_step_userasync_step_user()
143 
144  async def _async_start_link(self) -> ConfigFlowResult:
145  """Start linking."""
146  assert self.hosthost
147  device = self.discovered_robotsdiscovered_robots[self.hosthost]
148  self.blidblid = device.blid
149  self.namename = device.robot_name
150  await self.async_set_unique_idasync_set_unique_id(self.blidblid, raise_on_progress=False)
151  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
152  return await self.async_step_linkasync_step_link()
153 
154  async def async_step_user(
155  self, user_input: dict[str, Any] | None = None
156  ) -> ConfigFlowResult:
157  """Handle a flow start."""
158  # Check if user chooses manual entry
159  if user_input is not None and not user_input.get(CONF_HOST):
160  return await self.async_step_manualasync_step_manual()
161 
162  if (
163  user_input is not None
164  and self.discovered_robotsdiscovered_robots is not None
165  and user_input[CONF_HOST] in self.discovered_robotsdiscovered_robots
166  ):
167  self.hosthost = user_input[CONF_HOST]
168  return await self._async_start_link_async_start_link()
169 
170  already_configured = self._async_current_ids_async_current_ids(False)
171 
172  devices = await _async_discover_roombas(self.hass, self.hosthost)
173 
174  if devices:
175  # Find already configured hosts
176  self.discovered_robotsdiscovered_robots = {
177  device.ip: device
178  for device in devices
179  if device.blid not in already_configured
180  }
181 
182  if self.hosthost and self.hosthost in self.discovered_robotsdiscovered_robots:
183  # From discovery
184  self.context["title_placeholders"] = {
185  "host": self.hosthost,
186  "name": self.discovered_robotsdiscovered_robots[self.hosthost].robot_name,
187  }
188  return await self._async_start_link_async_start_link()
189 
190  if not self.discovered_robotsdiscovered_robots:
191  return await self.async_step_manualasync_step_manual()
192 
193  hosts: dict[str | None, str] = {
194  **{
195  device.ip: f"{device.robot_name} ({device.ip})"
196  for device in devices
197  if device.blid not in already_configured
198  },
199  None: "Manually add a Roomba or Braava",
200  }
201 
202  return self.async_show_formasync_show_formasync_show_form(
203  step_id="user",
204  data_schema=vol.Schema({vol.Optional("host"): vol.In(hosts)}),
205  )
206 
207  async def async_step_manual(
208  self, user_input: dict[str, Any] | None = None
209  ) -> ConfigFlowResult:
210  """Handle manual device setup."""
211  if user_input is None:
212  return self.async_show_formasync_show_formasync_show_form(
213  step_id="manual",
214  description_placeholders={AUTH_HELP_URL_KEY: AUTH_HELP_URL_VALUE},
215  data_schema=vol.Schema(
216  {vol.Required(CONF_HOST, default=self.hosthost): str}
217  ),
218  )
219 
220  self._async_abort_entries_match_async_abort_entries_match({CONF_HOST: user_input["host"]})
221 
222  self.hosthost = user_input[CONF_HOST]
223 
224  devices = await _async_discover_roombas(self.hass, self.hosthost)
225  if not devices:
226  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
227  self.blidblid = devices[0].blid
228  self.namename = devices[0].robot_name
229 
230  await self.async_set_unique_idasync_set_unique_id(self.blidblid, raise_on_progress=False)
231  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
232  return await self.async_step_linkasync_step_link()
233 
234  async def async_step_link(
235  self, user_input: dict[str, Any] | None = None
236  ) -> ConfigFlowResult:
237  """Attempt to link with the Roomba.
238 
239  Given a configured host, will ask the user to press the home and target buttons
240  to connect to the device.
241  """
242  if user_input is None:
243  return self.async_show_formasync_show_formasync_show_form(
244  step_id="link",
245  description_placeholders={CONF_NAME: self.namename or self.blidblid},
246  )
247  assert self.hosthost
248  roomba_pw = RoombaPassword(self.hosthost)
249 
250  try:
251  password = await self.hass.async_add_executor_job(roomba_pw.get_password)
252  except OSError:
253  return await self.async_step_link_manualasync_step_link_manual()
254 
255  if not password:
256  return await self.async_step_link_manualasync_step_link_manual()
257 
258  config = {
259  CONF_HOST: self.hosthost,
260  CONF_BLID: self.blidblid,
261  CONF_PASSWORD: password,
262  **DEFAULT_OPTIONS,
263  }
264 
265  if not self.namename:
266  try:
267  info = await validate_input(self.hass, config)
268  except CannotConnect:
269  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
270 
271  self.namename = info[CONF_NAME]
272  assert self.namename
273  return self.async_create_entryasync_create_entryasync_create_entry(title=self.namename, data=config)
274 
276  self, user_input: dict[str, Any] | None = None
277  ) -> ConfigFlowResult:
278  """Handle manual linking."""
279  errors = {}
280 
281  if user_input is not None:
282  config = {
283  CONF_HOST: self.hosthost,
284  CONF_BLID: self.blidblid,
285  CONF_PASSWORD: user_input[CONF_PASSWORD],
286  **DEFAULT_OPTIONS,
287  }
288  try:
289  info = await validate_input(self.hass, config)
290  except CannotConnect:
291  errors = {"base": "cannot_connect"}
292  else:
293  return self.async_create_entryasync_create_entryasync_create_entry(title=info[CONF_NAME], data=config)
294 
295  return self.async_show_formasync_show_formasync_show_form(
296  step_id="link_manual",
297  description_placeholders={AUTH_HELP_URL_KEY: AUTH_HELP_URL_VALUE},
298  data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
299  errors=errors,
300  )
301 
302 
304  """Handle options."""
305 
306  async def async_step_init(
307  self, user_input: dict[str, Any] | None = None
308  ) -> ConfigFlowResult:
309  """Manage the options."""
310  if user_input is not None:
311  return self.async_create_entryasync_create_entry(title="", data=user_input)
312 
313  options = self.config_entryconfig_entryconfig_entry.options
314  return self.async_show_formasync_show_form(
315  step_id="init",
316  data_schema=vol.Schema(
317  {
318  vol.Optional(
319  CONF_CONTINUOUS,
320  default=options.get(CONF_CONTINUOUS, DEFAULT_CONTINUOUS),
321  ): bool,
322  vol.Optional(
323  CONF_DELAY,
324  default=options.get(CONF_DELAY, DEFAULT_DELAY),
325  ): int,
326  }
327  ),
328  )
329 
330 
331 @callback
332 def _async_get_roomba_discovery() -> RoombaDiscovery:
333  """Create a discovery object."""
334  discovery = RoombaDiscovery()
335  discovery.amount_of_broadcasted_messages = MAX_NUM_DEVICES_TO_DISCOVER
336  return discovery
337 
338 
339 @callback
340 def _async_blid_from_hostname(hostname: str) -> str:
341  """Extract the blid from the hostname."""
342  return hostname.split("-")[1].split(".")[0].upper()
343 
344 
346  hass: HomeAssistant, host: str | None = None
347 ) -> list[RoombaInfo]:
348  discovered_hosts: set[str] = set()
349  devices: list[RoombaInfo] = []
350  discover_lock = hass.data.setdefault(ROOMBA_DISCOVERY_LOCK, asyncio.Lock())
351  discover_attempts = HOST_ATTEMPTS if host else ALL_ATTEMPTS
352 
353  for attempt in range(discover_attempts + 1):
354  async with discover_lock:
355  discovery = _async_get_roomba_discovery()
356  discovered: set[RoombaInfo] = set()
357  try:
358  if host:
359  device = await hass.async_add_executor_job(discovery.get, host)
360  if device:
361  discovered.add(device)
362  else:
363  discovered = await hass.async_add_executor_job(discovery.get_all)
364  except OSError:
365  # Socket temporarily unavailable
366  await asyncio.sleep(ROOMBA_WAKE_TIME * attempt)
367  continue
368  else:
369  for device in discovered:
370  if device.ip in discovered_hosts:
371  continue
372  discovered_hosts.add(device.ip)
373  devices.append(device)
374  finally:
375  discovery.server_socket.close()
376 
377  if host and host in discovered_hosts:
378  return devices
379 
380  await asyncio.sleep(ROOMBA_WAKE_TIME)
381 
382  return devices
ConfigFlowResult _async_step_discovery(self, str ip_address, str hostname)
Definition: config_flow.py:115
ConfigFlowResult async_step_link_manual(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:277
ConfigFlowResult async_step_dhcp(self, dhcp.DhcpServiceInfo discovery_info)
Definition: config_flow.py:107
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:156
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:99
RoombaOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:93
ConfigFlowResult async_step_manual(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:209
ConfigFlowResult async_step_link(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:236
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:308
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[ConfigFlowResult] _async_in_progress(self, bool include_uninitialized=False, dict[str, Any]|None match_context=None)
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)
None _async_abort_entries_match(self, dict[str, Any]|None match_dict=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)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
list[RoombaInfo] _async_discover_roombas(HomeAssistant hass, str|None host=None)
Definition: config_flow.py:347
dict[str, Any] validate_input(HomeAssistant hass, dict[str, Any] data)
Definition: config_flow.py:49
dict[str, Any] async_connect_or_timeout(HomeAssistant hass, Roomba roomba)
Definition: __init__.py:78
None async_disconnect_or_timeout(HomeAssistant hass, Roomba roomba)
Definition: __init__.py:103