Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for ZHA."""
2 
3 from __future__ import annotations
4 
5 import collections
6 from contextlib import suppress
7 import json
8 from typing import Any
9 
10 import serial.tools.list_ports
11 from serial.tools.list_ports_common import ListPortInfo
12 import voluptuous as vol
13 from zha.application.const import RadioType
14 import zigpy.backups
15 from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
16 
17 from homeassistant.components import onboarding, usb, zeroconf
18 from homeassistant.components.file_upload import process_uploaded_file
19 from homeassistant.components.hassio import AddonError, AddonState
20 from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
21 from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
22 from homeassistant.config_entries import (
23  SOURCE_IGNORE,
24  SOURCE_ZEROCONF,
25  ConfigEntry,
26  ConfigEntryBaseFlow,
27  ConfigEntryState,
28  ConfigFlow,
29  ConfigFlowResult,
30  OperationNotAllowed,
31  OptionsFlow,
32 )
33 from homeassistant.const import CONF_NAME
34 from homeassistant.core import HomeAssistant, callback
35 from homeassistant.exceptions import HomeAssistantError
36 from homeassistant.helpers.hassio import is_hassio
37 from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
38 from homeassistant.util import dt as dt_util
39 
40 from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
41 from .radio_manager import (
42  DEVICE_SCHEMA,
43  HARDWARE_DISCOVERY_SCHEMA,
44  RECOMMENDED_RADIOS,
45  ProbeResult,
46  ZhaRadioManager,
47 )
48 
49 CONF_MANUAL_PATH = "Enter Manually"
50 SUPPORTED_PORT_SETTINGS = (
51  CONF_BAUDRATE,
52  CONF_FLOW_CONTROL,
53 )
54 DECONZ_DOMAIN = "deconz"
55 
56 FORMATION_STRATEGY = "formation_strategy"
57 FORMATION_FORM_NEW_NETWORK = "form_new_network"
58 FORMATION_FORM_INITIAL_NETWORK = "form_initial_network"
59 FORMATION_REUSE_SETTINGS = "reuse_settings"
60 FORMATION_CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup"
61 FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup"
62 
63 CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup"
64 OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee"
65 
66 OPTIONS_INTENT_MIGRATE = "intent_migrate"
67 OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
68 
69 UPLOADED_BACKUP_FILE = "uploaded_backup_file"
70 
71 REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/"
72 
73 LEGACY_ZEROCONF_PORT = 6638
74 LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053
75 
76 ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local."
77 ZEROCONF_PROPERTIES_SCHEMA = vol.Schema(
78  {
79  vol.Required("radio_type"): vol.All(str, vol.In([t.name for t in RadioType])),
80  vol.Required("serial_number"): str,
81  },
82  extra=vol.ALLOW_EXTRA,
83 )
84 
85 
87  backup: zigpy.backups.NetworkBackup, *, pan_ids: bool = True
88 ) -> str:
89  """Format network backup info into a short piece of text."""
90  if not pan_ids:
91  return dt_util.as_local(backup.backup_time).strftime("%c")
92 
93  identifier = (
94  # PAN ID
95  f"{str(backup.network_info.pan_id)[2:]}"
96  # EPID
97  f":{str(backup.network_info.extended_pan_id).replace(':', '')}"
98  ).lower()
99 
100  return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})"
101 
102 
103 async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
104  """List all serial ports, including the Yellow radio and the multi-PAN addon."""
105  ports = await hass.async_add_executor_job(serial.tools.list_ports.comports)
106 
107  # Add useful info to the Yellow's serial port selection screen
108  try:
109  yellow_hardware.async_info(hass)
110  except HomeAssistantError:
111  pass
112  else:
113  yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1")
114  yellow_radio.description = "Yellow Zigbee module"
115  yellow_radio.manufacturer = "Nabu Casa"
116 
117  if is_hassio(hass):
118  # Present the multi-PAN addon as a setup option, if it's available
119  multipan_manager = (
120  await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass)
121  )
122 
123  try:
124  addon_info = await multipan_manager.async_get_addon_info()
125  except (AddonError, KeyError):
126  addon_info = None
127 
128  if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
129  addon_port = ListPortInfo(
130  device=silabs_multiprotocol_addon.get_zigbee_socket(),
131  skip_link_detection=True,
132  )
133 
134  addon_port.description = "Multiprotocol add-on"
135  addon_port.manufacturer = "Nabu Casa"
136  ports.append(addon_port)
137 
138  return ports
139 
140 
142  """Mixin for common ZHA flow steps and forms."""
143 
144  _hass: HomeAssistant
145  _title: str
146 
147  def __init__(self) -> None:
148  """Initialize flow instance."""
149  super().__init__()
150 
151  self._hass_hass = None # type: ignore[assignment]
152  self._radio_mgr_radio_mgr = ZhaRadioManager()
153 
154  @property
155  def hass(self) -> HomeAssistant:
156  """Return hass."""
157  return self._hass_hass
158 
159  @hass.setter
160  def hass(self, hass: HomeAssistant) -> None:
161  """Set hass."""
162  self._hass_hass = hass
163  self._radio_mgr_radio_mgr.hass = hass
164 
165  async def _async_create_radio_entry(self) -> ConfigFlowResult:
166  """Create a config entry with the current flow state."""
167  assert self._radio_mgr_radio_mgr.radio_type is not None
168  assert self._radio_mgr_radio_mgr.device_path is not None
169  assert self._radio_mgr_radio_mgr.device_settings is not None
170 
171  device_settings = self._radio_mgr_radio_mgr.device_settings.copy()
172  device_settings[CONF_DEVICE_PATH] = await self.hasshasshass.async_add_executor_job(
173  usb.get_serial_by_id, self._radio_mgr_radio_mgr.device_path
174  )
175 
176  return self.async_create_entryasync_create_entry(
177  title=self._title_title,
178  data={
179  CONF_DEVICE: DEVICE_SCHEMA(device_settings),
180  CONF_RADIO_TYPE: self._radio_mgr_radio_mgr.radio_type.name,
181  },
182  )
183 
185  self, user_input: dict[str, Any] | None = None
186  ) -> ConfigFlowResult:
187  """Choose a serial port."""
188  ports = await list_serial_ports(self.hasshasshass)
189  list_of_ports = [
190  f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}"
191  + (f" - {p.manufacturer}" if p.manufacturer else "")
192  for p in ports
193  ]
194 
195  if not list_of_ports:
196  return await self.async_step_manual_pick_radio_typeasync_step_manual_pick_radio_type()
197 
198  list_of_ports.append(CONF_MANUAL_PATH)
199 
200  if user_input is not None:
201  user_selection = user_input[CONF_DEVICE_PATH]
202 
203  if user_selection == CONF_MANUAL_PATH:
204  return await self.async_step_manual_pick_radio_typeasync_step_manual_pick_radio_type()
205 
206  port = ports[list_of_ports.index(user_selection)]
207  self._radio_mgr_radio_mgr.device_path = port.device
208 
209  probe_result = await self._radio_mgr_radio_mgr.detect_radio_type()
210  if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED:
211  return self.async_abortasync_abort(
212  reason="wrong_firmware_installed",
213  description_placeholders={"repair_url": REPAIR_MY_URL},
214  )
215  if probe_result == ProbeResult.PROBING_FAILED:
216  # Did not autodetect anything, proceed to manual selection
217  return await self.async_step_manual_pick_radio_typeasync_step_manual_pick_radio_type()
218 
219  self._title_title = (
220  f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}"
221  f" - {port.manufacturer}"
222  if port.manufacturer
223  else ""
224  )
225 
226  return await self.async_step_verify_radioasync_step_verify_radio()
227 
228  # Pre-select the currently configured port
229  default_port: vol.Undefined | str = vol.UNDEFINED
230 
231  if self._radio_mgr_radio_mgr.device_path is not None:
232  for description, port in zip(list_of_ports, ports, strict=False):
233  if port.device == self._radio_mgr_radio_mgr.device_path:
234  default_port = description
235  break
236  else:
237  default_port = CONF_MANUAL_PATH
238 
239  schema = vol.Schema(
240  {
241  vol.Required(CONF_DEVICE_PATH, default=default_port): vol.In(
242  list_of_ports
243  )
244  }
245  )
246  return self.async_show_formasync_show_form(step_id="choose_serial_port", data_schema=schema)
247 
249  self, user_input: dict[str, Any] | None = None
250  ) -> ConfigFlowResult:
251  """Manually select the radio type."""
252  if user_input is not None:
253  self._radio_mgr_radio_mgr.radio_type = RadioType.get_by_description(
254  user_input[CONF_RADIO_TYPE]
255  )
256  return await self.async_step_manual_port_configasync_step_manual_port_config()
257 
258  # Pre-select the current radio type
259  default: vol.Undefined | str = vol.UNDEFINED
260 
261  if self._radio_mgr_radio_mgr.radio_type is not None:
262  default = self._radio_mgr_radio_mgr.radio_type.description
263 
264  schema = {
265  vol.Required(CONF_RADIO_TYPE, default=default): vol.In(RadioType.list())
266  }
267 
268  return self.async_show_formasync_show_form(
269  step_id="manual_pick_radio_type",
270  data_schema=vol.Schema(schema),
271  )
272 
274  self, user_input: dict[str, Any] | None = None
275  ) -> ConfigFlowResult:
276  """Enter port settings specific for this type of radio."""
277  assert self._radio_mgr_radio_mgr.radio_type is not None
278  errors = {}
279 
280  if user_input is not None:
281  self._title_title = user_input[CONF_DEVICE_PATH]
282  self._radio_mgr_radio_mgr.device_path = user_input[CONF_DEVICE_PATH]
283  self._radio_mgr_radio_mgr.device_settings = user_input.copy()
284 
285  if await self._radio_mgr_radio_mgr.radio_type.controller.probe(user_input):
286  return await self.async_step_verify_radioasync_step_verify_radio()
287 
288  errors["base"] = "cannot_connect"
289 
290  schema = {
291  vol.Required(
292  CONF_DEVICE_PATH, default=self._radio_mgr_radio_mgr.device_path or vol.UNDEFINED
293  ): str
294  }
295 
296  source = self.context.get("source")
297  for (
298  param,
299  value,
300  ) in DEVICE_SCHEMA.schema.items():
301  if param not in SUPPORTED_PORT_SETTINGS:
302  continue
303 
304  if source == SOURCE_ZEROCONF and param == CONF_BAUDRATE:
305  value = 115200
306  param = vol.Required(CONF_BAUDRATE, default=value)
307  elif (
308  self._radio_mgr_radio_mgr.device_settings is not None
309  and param in self._radio_mgr_radio_mgr.device_settings
310  ):
311  param = vol.Required(
312  str(param), default=self._radio_mgr_radio_mgr.device_settings[param]
313  )
314 
315  schema[param] = value
316 
317  return self.async_show_formasync_show_form(
318  step_id="manual_port_config",
319  data_schema=vol.Schema(schema),
320  errors=errors,
321  )
322 
324  self, user_input: dict[str, Any] | None = None
325  ) -> ConfigFlowResult:
326  """Add a warning step to dissuade the use of deprecated radios."""
327  assert self._radio_mgr_radio_mgr.radio_type is not None
328 
329  # Skip this step if we are using a recommended radio
330  if user_input is not None or self._radio_mgr_radio_mgr.radio_type in RECOMMENDED_RADIOS:
331  return await self.async_step_choose_formation_strategyasync_step_choose_formation_strategy()
332 
333  return self.async_show_formasync_show_form(
334  step_id="verify_radio",
335  description_placeholders={
336  CONF_NAME: self._radio_mgr_radio_mgr.radio_type.description,
337  "docs_recommended_adapters_url": (
338  "https://www.home-assistant.io/integrations/zha/#recommended-zigbee-radio-adapters-and-modules"
339  ),
340  },
341  )
342 
344  self, user_input: dict[str, Any] | None = None
345  ) -> ConfigFlowResult:
346  """Choose how to deal with the current radio's settings."""
347  await self._radio_mgr_radio_mgr.async_load_network_settings()
348 
349  strategies = []
350 
351  # Check if we have any automatic backups *and* if the backups differ from
352  # the current radio settings, if they exist (since restoring would be redundant)
353  if self._radio_mgr_radio_mgr.backups and (
354  self._radio_mgr_radio_mgr.current_settings is None
355  or any(
356  not backup.is_compatible_with(self._radio_mgr_radio_mgr.current_settings)
357  for backup in self._radio_mgr_radio_mgr.backups
358  )
359  ):
360  strategies.append(CHOOSE_AUTOMATIC_BACKUP)
361 
362  if self._radio_mgr_radio_mgr.current_settings is not None:
363  strategies.append(FORMATION_REUSE_SETTINGS)
364 
365  strategies.append(FORMATION_UPLOAD_MANUAL_BACKUP)
366 
367  # Do not show "erase network settings" if there are none to erase
368  if self._radio_mgr_radio_mgr.current_settings is None:
369  strategies.append(FORMATION_FORM_INITIAL_NETWORK)
370  else:
371  strategies.append(FORMATION_FORM_NEW_NETWORK)
372 
373  # Automatically form a new network if we're onboarding with a brand new radio
374  if not onboarding.async_is_onboarded(self.hasshasshass) and set(strategies) == {
375  FORMATION_UPLOAD_MANUAL_BACKUP,
376  FORMATION_FORM_INITIAL_NETWORK,
377  }:
378  return await self.async_step_form_initial_networkasync_step_form_initial_network()
379 
380  # Otherwise, let the user choose
381  return self.async_show_menuasync_show_menu(
382  step_id="choose_formation_strategy",
383  menu_options=strategies,
384  )
385 
387  self, user_input: dict[str, Any] | None = None
388  ) -> ConfigFlowResult:
389  """Reuse the existing network settings on the stick."""
390  return await self._async_create_radio_entry_async_create_radio_entry()
391 
393  self, user_input: dict[str, Any] | None = None
394  ) -> ConfigFlowResult:
395  """Form an initial network."""
396  # This step exists only for translations, it does nothing new
397  return await self.async_step_form_new_networkasync_step_form_new_network(user_input)
398 
400  self, user_input: dict[str, Any] | None = None
401  ) -> ConfigFlowResult:
402  """Form a brand-new network."""
403  await self._radio_mgr_radio_mgr.async_form_network()
404  return await self._async_create_radio_entry_async_create_radio_entry()
405 
407  self, uploaded_file_id: str
408  ) -> zigpy.backups.NetworkBackup:
409  """Read and parse an uploaded backup JSON file."""
410  with process_uploaded_file(self.hasshasshass, uploaded_file_id) as file_path:
411  contents = file_path.read_text()
412 
413  return zigpy.backups.NetworkBackup.from_dict(json.loads(contents))
414 
416  self, user_input: dict[str, Any] | None = None
417  ) -> ConfigFlowResult:
418  """Upload and restore a coordinator backup JSON file."""
419  errors = {}
420 
421  if user_input is not None:
422  try:
423  self._radio_mgr_radio_mgr.chosen_backup = await self.hasshasshass.async_add_executor_job(
424  self._parse_uploaded_backup_parse_uploaded_backup, user_input[UPLOADED_BACKUP_FILE]
425  )
426  except ValueError:
427  errors["base"] = "invalid_backup_json"
428  else:
429  return await self.async_step_maybe_confirm_ezsp_restoreasync_step_maybe_confirm_ezsp_restore()
430 
431  return self.async_show_formasync_show_form(
432  step_id="upload_manual_backup",
433  data_schema=vol.Schema(
434  {
435  vol.Required(UPLOADED_BACKUP_FILE): FileSelector(
436  FileSelectorConfig(accept=".json,application/json")
437  )
438  }
439  ),
440  errors=errors,
441  )
442 
444  self, user_input: dict[str, Any] | None = None
445  ) -> ConfigFlowResult:
446  """Choose an automatic backup."""
447  if self.show_advanced_optionsshow_advanced_options:
448  # Always show the PAN IDs when in advanced mode
449  choices = [
450  _format_backup_choice(backup, pan_ids=True)
451  for backup in self._radio_mgr_radio_mgr.backups
452  ]
453  else:
454  # Only show the PAN IDs for multiple backups taken on the same day
455  num_backups_on_date = collections.Counter(
456  backup.backup_time.date() for backup in self._radio_mgr_radio_mgr.backups
457  )
458  choices = [
460  backup, pan_ids=(num_backups_on_date[backup.backup_time.date()] > 1)
461  )
462  for backup in self._radio_mgr_radio_mgr.backups
463  ]
464 
465  if user_input is not None:
466  index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP])
467  self._radio_mgr_radio_mgr.chosen_backup = self._radio_mgr_radio_mgr.backups[index]
468 
469  return await self.async_step_maybe_confirm_ezsp_restoreasync_step_maybe_confirm_ezsp_restore()
470 
471  return self.async_show_formasync_show_form(
472  step_id="choose_automatic_backup",
473  data_schema=vol.Schema(
474  {
475  vol.Required(CHOOSE_AUTOMATIC_BACKUP, default=choices[0]): vol.In(
476  choices
477  ),
478  }
479  ),
480  )
481 
483  self, user_input: dict[str, Any] | None = None
484  ) -> ConfigFlowResult:
485  """Confirm restore for EZSP radios that require permanent IEEE writes."""
486  call_step_2 = await self._radio_mgr_radio_mgr.async_restore_backup_step_1()
487  if not call_step_2:
488  return await self._async_create_radio_entry_async_create_radio_entry()
489 
490  if user_input is not None:
491  await self._radio_mgr_radio_mgr.async_restore_backup_step_2(
492  user_input[OVERWRITE_COORDINATOR_IEEE]
493  )
494  return await self._async_create_radio_entry_async_create_radio_entry()
495 
496  return self.async_show_formasync_show_form(
497  step_id="maybe_confirm_ezsp_restore",
498  data_schema=vol.Schema(
499  {vol.Required(OVERWRITE_COORDINATOR_IEEE, default=True): bool}
500  ),
501  )
502 
503 
505  """Handle a config flow."""
506 
507  VERSION = 4
508 
510  self, unique_id: str, device_path: str
511  ) -> None:
512  """Set the flow's unique ID and update the device path in an ignored flow."""
513  current_entry = await self.async_set_unique_idasync_set_unique_id(unique_id)
514 
515  if not current_entry:
516  return
517 
518  if current_entry.source != SOURCE_IGNORE:
519  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
520  else:
521  # Only update the current entry if it is an ignored discovery
522  self._abort_if_unique_id_configured_abort_if_unique_id_configured(
523  updates={
524  CONF_DEVICE: {
525  **current_entry.data.get(CONF_DEVICE, {}),
526  CONF_DEVICE_PATH: device_path,
527  },
528  }
529  )
530 
531  @staticmethod
532  @callback
534  config_entry: ConfigEntry,
535  ) -> OptionsFlow:
536  """Create the options flow."""
537  return ZhaOptionsFlowHandler(config_entry)
538 
539  async def async_step_user(
540  self, user_input: dict[str, Any] | None = None
541  ) -> ConfigFlowResult:
542  """Handle a ZHA config flow start."""
543  if self._async_current_entries_async_current_entries():
544  return self.async_abortasync_abortasync_abort(reason="single_instance_allowed")
545 
546  return await self.async_step_choose_serial_portasync_step_choose_serial_port(user_input)
547 
549  self, user_input: dict[str, Any] | None = None
550  ) -> ConfigFlowResult:
551  """Confirm a discovery."""
552  self._set_confirm_only_set_confirm_only()
553 
554  # Don't permit discovery if ZHA is already set up
555  if self._async_current_entries_async_current_entries():
556  return self.async_abortasync_abortasync_abort(reason="single_instance_allowed")
557 
558  # Without confirmation, discovery can automatically progress into parts of the
559  # config flow logic that interacts with hardware.
560  if user_input is not None or not onboarding.async_is_onboarded(self.hasshasshass):
561  # Probe the radio type if we don't have one yet
562  if self._radio_mgr_radio_mgr.radio_type is None:
563  probe_result = await self._radio_mgr_radio_mgr.detect_radio_type()
564  else:
565  probe_result = ProbeResult.RADIO_TYPE_DETECTED
566 
567  if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED:
568  return self.async_abortasync_abortasync_abort(
569  reason="wrong_firmware_installed",
570  description_placeholders={"repair_url": REPAIR_MY_URL},
571  )
572  if probe_result == ProbeResult.PROBING_FAILED:
573  # This path probably will not happen now that we have
574  # more precise USB matching unless there is a problem
575  # with the device
576  return self.async_abortasync_abortasync_abort(reason="usb_probe_failed")
577 
578  if self._radio_mgr_radio_mgr.device_settings is None:
579  return await self.async_step_manual_port_configasync_step_manual_port_config()
580 
581  return await self.async_step_verify_radioasync_step_verify_radio()
582 
583  return self.async_show_formasync_show_formasync_show_form(
584  step_id="confirm",
585  description_placeholders={CONF_NAME: self._title_title_title},
586  )
587 
588  async def async_step_usb(
589  self, discovery_info: usb.UsbServiceInfo
590  ) -> ConfigFlowResult:
591  """Handle usb discovery."""
592  vid = discovery_info.vid
593  pid = discovery_info.pid
594  serial_number = discovery_info.serial_number
595  manufacturer = discovery_info.manufacturer
596  description = discovery_info.description
597  dev_path = discovery_info.device
598 
599  await self._set_unique_id_and_update_ignored_flow_set_unique_id_and_update_ignored_flow(
600  unique_id=f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}",
601  device_path=dev_path,
602  )
603 
604  # If they already have a discovery for deconz we ignore the usb discovery as
605  # they probably want to use it there instead
606  if self.hasshasshass.config_entries.flow.async_progress_by_handler(DECONZ_DOMAIN):
607  return self.async_abortasync_abortasync_abort(reason="not_zha_device")
608  for entry in self.hasshasshass.config_entries.async_entries(DECONZ_DOMAIN):
609  if entry.source != SOURCE_IGNORE:
610  return self.async_abortasync_abortasync_abort(reason="not_zha_device")
611 
612  self._radio_mgr_radio_mgr.device_path = dev_path
613  self._title_title_title = description or usb.human_readable_device_name(
614  dev_path,
615  serial_number,
616  manufacturer,
617  description,
618  vid,
619  pid,
620  )
621  self.context["title_placeholders"] = {CONF_NAME: self._title_title_title}
622  return await self.async_step_confirmasync_step_confirm()
623 
625  self, discovery_info: zeroconf.ZeroconfServiceInfo
626  ) -> ConfigFlowResult:
627  """Handle zeroconf discovery."""
628 
629  # Transform legacy zeroconf discovery into the new format
630  if discovery_info.type != ZEROCONF_SERVICE_TYPE:
631  port = discovery_info.port or LEGACY_ZEROCONF_PORT
632  name = discovery_info.name
633 
634  # Fix incorrect port for older TubesZB devices
635  if "tube" in name and port == LEGACY_ZEROCONF_ESPHOME_API_PORT:
636  port = LEGACY_ZEROCONF_PORT
637 
638  # Determine the radio type
639  if "radio_type" in discovery_info.properties:
640  radio_type = discovery_info.properties["radio_type"]
641  elif "efr32" in name:
642  radio_type = RadioType.ezsp.name
643  elif "zigate" in name:
644  radio_type = RadioType.zigate.name
645  else:
646  radio_type = RadioType.znp.name
647 
648  fallback_title = name.split("._", 1)[0]
649  title = discovery_info.properties.get("name", fallback_title)
650 
651  discovery_info = zeroconf.ZeroconfServiceInfo(
652  ip_address=discovery_info.ip_address,
653  ip_addresses=discovery_info.ip_addresses,
654  port=port,
655  hostname=discovery_info.hostname,
656  type=ZEROCONF_SERVICE_TYPE,
657  name=f"{title}.{ZEROCONF_SERVICE_TYPE}",
658  properties={
659  "radio_type": radio_type,
660  # To maintain backwards compatibility
661  "serial_number": discovery_info.hostname.removesuffix(".local."),
662  },
663  )
664 
665  try:
666  discovery_props = ZEROCONF_PROPERTIES_SCHEMA(discovery_info.properties)
667  except vol.Invalid:
668  return self.async_abortasync_abortasync_abort(reason="invalid_zeroconf_data")
669 
670  radio_type = self._radio_mgr_radio_mgr.parse_radio_type(discovery_props["radio_type"])
671  device_path = f"socket://{discovery_info.host}:{discovery_info.port}"
672  title = discovery_info.name.removesuffix(f".{ZEROCONF_SERVICE_TYPE}")
673 
674  await self._set_unique_id_and_update_ignored_flow_set_unique_id_and_update_ignored_flow(
675  unique_id=discovery_props["serial_number"],
676  device_path=device_path,
677  )
678 
679  self.context["title_placeholders"] = {CONF_NAME: title}
680  self._title_title_title = title
681  self._radio_mgr_radio_mgr.device_path = device_path
682  self._radio_mgr_radio_mgr.radio_type = radio_type
683  self._radio_mgr_radio_mgr.device_settings = {
684  CONF_DEVICE_PATH: device_path,
685  CONF_BAUDRATE: 115200,
686  CONF_FLOW_CONTROL: None,
687  }
688 
689  return await self.async_step_confirmasync_step_confirm()
690 
692  self, data: dict[str, Any] | None = None
693  ) -> ConfigFlowResult:
694  """Handle hardware flow."""
695  try:
696  discovery_data = HARDWARE_DISCOVERY_SCHEMA(data)
697  except vol.Invalid:
698  return self.async_abortasync_abortasync_abort(reason="invalid_hardware_data")
699 
700  name = discovery_data["name"]
701  radio_type = self._radio_mgr_radio_mgr.parse_radio_type(discovery_data["radio_type"])
702  device_settings = discovery_data["port"]
703  device_path = device_settings[CONF_DEVICE_PATH]
704 
705  await self._set_unique_id_and_update_ignored_flow_set_unique_id_and_update_ignored_flow(
706  unique_id=f"{name}_{radio_type.name}_{device_path}",
707  device_path=device_path,
708  )
709 
710  self._title_title_title = name
711  self._radio_mgr_radio_mgr.radio_type = radio_type
712  self._radio_mgr_radio_mgr.device_path = device_path
713  self._radio_mgr_radio_mgr.device_settings = device_settings
714  self.context["title_placeholders"] = {CONF_NAME: name}
715 
716  return await self.async_step_confirmasync_step_confirm()
717 
718 
720  """Handle an options flow."""
721 
722  def __init__(self, config_entry: ConfigEntry) -> None:
723  """Initialize options flow."""
724  super().__init__()
725  self._radio_mgr_radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
726  self._radio_mgr_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
727  self._radio_mgr_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
728  self._title_title_title = config_entry.title
729 
730  async def async_step_init(
731  self, user_input: dict[str, Any] | None = None
732  ) -> ConfigFlowResult:
733  """Launch the options flow."""
734  if user_input is not None:
735  # OperationNotAllowed: ZHA is not running
736  with suppress(OperationNotAllowed):
737  await self.hasshasshass.config_entries.async_unload(self.config_entryconfig_entryconfig_entry.entry_id)
738 
739  return await self.async_step_prompt_migrate_or_reconfigureasync_step_prompt_migrate_or_reconfigure()
740 
741  return self.async_show_formasync_show_form(step_id="init")
742 
744  self, user_input: dict[str, Any] | None = None
745  ) -> ConfigFlowResult:
746  """Confirm if we are migrating adapters or just re-configuring."""
747 
748  return self.async_show_menuasync_show_menu(
749  step_id="prompt_migrate_or_reconfigure",
750  menu_options=[
751  OPTIONS_INTENT_RECONFIGURE,
752  OPTIONS_INTENT_MIGRATE,
753  ],
754  )
755 
757  self, user_input: dict[str, Any] | None = None
758  ) -> ConfigFlowResult:
759  """Virtual step for when the user is reconfiguring the integration."""
760  return await self.async_step_choose_serial_portasync_step_choose_serial_port()
761 
763  self, user_input: dict[str, Any] | None = None
764  ) -> ConfigFlowResult:
765  """Confirm the user wants to reset their current radio."""
766 
767  if user_input is not None:
768  await self._radio_mgr_radio_mgr.async_reset_adapter()
769 
770  return await self.async_step_instruct_unplugasync_step_instruct_unplug()
771 
772  return self.async_show_formasync_show_form(step_id="intent_migrate")
773 
775  self, user_input: dict[str, Any] | None = None
776  ) -> ConfigFlowResult:
777  """Instruct the user to unplug the current radio, if possible."""
778 
779  if user_input is not None:
780  # Now that the old radio is gone, we can scan for serial ports again
781  return await self.async_step_choose_serial_portasync_step_choose_serial_port()
782 
783  return self.async_show_formasync_show_form(step_id="instruct_unplug")
784 
785  async def _async_create_radio_entry(self):
786  """Re-implementation of the base flow's final step to update the config."""
787  device_settings = self._radio_mgr_radio_mgr.device_settings.copy()
788  device_settings[CONF_DEVICE_PATH] = await self.hasshasshass.async_add_executor_job(
789  usb.get_serial_by_id, self._radio_mgr_radio_mgr.device_path
790  )
791 
792  # Avoid creating both `.options` and `.data` by directly writing `data` here
793  self.hasshasshass.config_entries.async_update_entry(
794  entry=self.config_entryconfig_entryconfig_entry,
795  data={
796  CONF_DEVICE: device_settings,
797  CONF_RADIO_TYPE: self._radio_mgr_radio_mgr.radio_type.name,
798  },
799  options=self.config_entryconfig_entryconfig_entry.options,
800  )
801 
802  # Reload ZHA after we finish
803  await self.hasshasshass.config_entries.async_setup(self.config_entryconfig_entryconfig_entry.entry_id)
804 
805  # Intentionally do not set `data` to avoid creating `options`, we set it above
806  return self.async_create_entryasync_create_entry(title=self._title_title_title, data={})
807 
808  def async_remove(self):
809  """Maybe reload ZHA if the flow is aborted."""
810  if self.config_entryconfig_entryconfig_entry.state not in (
811  ConfigEntryState.SETUP_ERROR,
812  ConfigEntryState.NOT_LOADED,
813  ):
814  return
815 
816  self.hasshasshass.async_create_task(
817  self.hasshasshass.config_entries.async_setup(self.config_entryconfig_entryconfig_entry.entry_id)
818  )
ConfigFlowResult async_step_verify_radio(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:325
ConfigFlowResult async_step_manual_port_config(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:275
ConfigFlowResult async_step_maybe_confirm_ezsp_restore(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:484
ConfigFlowResult async_step_reuse_settings(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:388
ConfigFlowResult async_step_choose_formation_strategy(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:345
ConfigFlowResult async_step_upload_manual_backup(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:417
ConfigFlowResult async_step_choose_serial_port(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:186
ConfigFlowResult async_step_form_new_network(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:401
zigpy.backups.NetworkBackup _parse_uploaded_backup(self, str uploaded_file_id)
Definition: config_flow.py:408
ConfigFlowResult async_step_choose_automatic_backup(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:445
ConfigFlowResult async_step_manual_pick_radio_type(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:250
ConfigFlowResult async_step_form_initial_network(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:394
ConfigFlowResult async_step_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:550
OptionsFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:535
ConfigFlowResult async_step_hardware(self, dict[str, Any]|None data=None)
Definition: config_flow.py:693
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:541
ConfigFlowResult async_step_usb(self, usb.UsbServiceInfo discovery_info)
Definition: config_flow.py:590
None _set_unique_id_and_update_ignored_flow(self, str unique_id, str device_path)
Definition: config_flow.py:511
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:626
ConfigFlowResult async_step_prompt_migrate_or_reconfigure(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:745
ConfigFlowResult async_step_intent_reconfigure(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:758
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:732
ConfigFlowResult async_step_instruct_unplug(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:776
ConfigFlowResult async_step_intent_migrate(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:764
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)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=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)
bool show_advanced_options(self)
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_show_menu(self, *str|None step_id=None, Container[str] menu_options, Mapping[str, str]|None description_placeholders=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
Iterator[Path] process_uploaded_file(HomeAssistant hass, str file_id)
Definition: __init__.py:36
bool is_hassio(HomeAssistant hass)
Definition: __init__.py:302
str _format_backup_choice(zigpy.backups.NetworkBackup backup, *bool pan_ids=True)
Definition: config_flow.py:88
list[ListPortInfo] list_serial_ports(HomeAssistant hass)
Definition: config_flow.py:103