Home Assistant Unofficial Reference 2024.12.1
radio_manager.py
Go to the documentation of this file.
1 """Config flow for ZHA."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import AsyncIterator
7 import contextlib
8 from contextlib import suppress
9 import copy
10 import enum
11 import logging
12 import os
13 from typing import Any, Self
14 
15 from bellows.config import CONF_USE_THREAD
16 import voluptuous as vol
17 from zha.application.const import RadioType
18 from zigpy.application import ControllerApplication
19 import zigpy.backups
20 from zigpy.config import (
21  CONF_DATABASE,
22  CONF_DEVICE,
23  CONF_DEVICE_PATH,
24  CONF_NWK_BACKUP_ENABLED,
25  SCHEMA_DEVICE,
26 )
27 from zigpy.exceptions import NetworkNotFormed
28 
29 from homeassistant import config_entries
30 from homeassistant.components import usb
31 from homeassistant.core import HomeAssistant
32 
33 from . import repairs
34 from .const import (
35  CONF_RADIO_TYPE,
36  CONF_ZIGPY,
37  DEFAULT_DATABASE_NAME,
38  EZSP_OVERWRITE_EUI64,
39 )
40 from .helpers import get_zha_data
41 
42 # Only the common radio types will be autoprobed, ordered by new device popularity.
43 # XBee takes too long to probe since it scans through all possible bauds and likely has
44 # very few users to begin with.
45 AUTOPROBE_RADIOS = (
46  RadioType.ezsp,
47  RadioType.znp,
48  RadioType.deconz,
49  RadioType.zigate,
50 )
51 
52 RECOMMENDED_RADIOS = (
53  RadioType.ezsp,
54  RadioType.znp,
55  RadioType.deconz,
56 )
57 
58 CONNECT_DELAY_S = 1.0
59 RETRY_DELAY_S = 1.0
60 
61 BACKUP_RETRIES = 5
62 MIGRATION_RETRIES = 100
63 
64 
65 DEVICE_SCHEMA = vol.Schema(
66  {
67  vol.Required("path"): str,
68  vol.Optional("baudrate", default=115200): int,
69  vol.Optional("flow_control", default=None): vol.In(
70  ["hardware", "software", None]
71  ),
72  }
73 )
74 
75 HARDWARE_DISCOVERY_SCHEMA = vol.Schema(
76  {
77  vol.Required("name"): str,
78  vol.Required("port"): DEVICE_SCHEMA,
79  vol.Required("radio_type"): str,
80  }
81 )
82 
83 HARDWARE_MIGRATION_SCHEMA = vol.Schema(
84  {
85  vol.Required("new_discovery_info"): HARDWARE_DISCOVERY_SCHEMA,
86  vol.Required("old_discovery_info"): vol.Schema(
87  {
88  vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA,
89  vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo,
90  }
91  ),
92  }
93 )
94 
95 _LOGGER = logging.getLogger(__name__)
96 
97 
98 class ProbeResult(enum.StrEnum):
99  """Radio firmware probing result."""
100 
101  RADIO_TYPE_DETECTED = "radio_type_detected"
102  WRONG_FIRMWARE_INSTALLED = "wrong_firmware_installed"
103  PROBING_FAILED = "probing_failed"
104 
105 
107  backup: zigpy.backups.NetworkBackup,
108 ) -> zigpy.backups.NetworkBackup:
109  """Return a new backup with the flag to allow overwriting the EZSP EUI64."""
110  new_stack_specific = copy.deepcopy(backup.network_info.stack_specific)
111  new_stack_specific.setdefault("ezsp", {})[EZSP_OVERWRITE_EUI64] = True
112 
113  return backup.replace(
114  network_info=backup.network_info.replace(stack_specific=new_stack_specific)
115  )
116 
117 
119  backup: zigpy.backups.NetworkBackup,
120 ) -> zigpy.backups.NetworkBackup:
121  """Return a new backup without the flag to allow overwriting the EZSP EUI64."""
122  if "ezsp" not in backup.network_info.stack_specific:
123  return backup
124 
125  new_stack_specific = copy.deepcopy(backup.network_info.stack_specific)
126  new_stack_specific.setdefault("ezsp", {}).pop(EZSP_OVERWRITE_EUI64, None)
127 
128  return backup.replace(
129  network_info=backup.network_info.replace(stack_specific=new_stack_specific)
130  )
131 
132 
134  """Helper class with radio related functionality."""
135 
136  hass: HomeAssistant
137 
138  def __init__(self) -> None:
139  """Initialize ZhaRadioManager instance."""
140  self.device_path: str | None = None
141  self.device_settingsdevice_settings: dict[str, Any] | None = None
142  self.radio_typeradio_type: RadioType | None = None
143  self.current_settingscurrent_settings: zigpy.backups.NetworkBackup | None = None
144  self.backupsbackups: list[zigpy.backups.NetworkBackup] = []
145  self.chosen_backup: zigpy.backups.NetworkBackup | None = None
146 
147  @classmethod
149  cls, hass: HomeAssistant, config_entry: config_entries.ConfigEntry
150  ) -> Self:
151  """Create an instance from a config entry."""
152  mgr = cls()
153  mgr.hass = hass
154  mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
155  mgr.device_settings = config_entry.data[CONF_DEVICE]
156  mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
157 
158  return mgr
159 
160  @contextlib.asynccontextmanager
161  async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]:
162  """Connect to the radio with the current config and then clean up."""
163  assert self.radio_typeradio_type is not None
164 
165  config = get_zha_data(self.hass).yaml_config
166  app_config = config.get(CONF_ZIGPY, {}).copy()
167 
168  database_path = config.get(
169  CONF_DATABASE,
170  self.hass.config.path(DEFAULT_DATABASE_NAME),
171  )
172 
173  # Don't create `zigbee.db` if it doesn't already exist
174  if not await self.hass.async_add_executor_job(os.path.exists, database_path):
175  database_path = None
176 
177  app_config[CONF_DATABASE] = database_path
178  app_config[CONF_DEVICE] = self.device_settingsdevice_settings
179  app_config[CONF_NWK_BACKUP_ENABLED] = False
180  app_config[CONF_USE_THREAD] = False
181 
182  app = await self.radio_typeradio_type.controller.new(
183  app_config, auto_form=False, start_radio=False
184  )
185 
186  try:
187  yield app
188  finally:
189  await app.shutdown()
190  await asyncio.sleep(CONNECT_DELAY_S)
191 
192  async def restore_backup(
193  self, backup: zigpy.backups.NetworkBackup, **kwargs: Any
194  ) -> None:
195  """Restore the provided network backup, passing through kwargs."""
196  if self.current_settingscurrent_settings is not None and self.current_settingscurrent_settings.supersedes(
197  self.chosen_backup
198  ):
199  return
200 
201  async with self.connect_zigpy_appconnect_zigpy_app() as app:
202  await app.connect()
203  await app.backups.restore_backup(backup, **kwargs)
204 
205  @staticmethod
206  def parse_radio_type(radio_type: str) -> RadioType:
207  """Parse a radio type name, accounting for past aliases."""
208  if radio_type == "efr32":
209  return RadioType.ezsp
210 
211  return RadioType[radio_type]
212 
213  async def detect_radio_type(self) -> ProbeResult:
214  """Probe all radio types on the current port."""
215  assert self.device_path is not None
216 
217  for radio in AUTOPROBE_RADIOS:
218  _LOGGER.debug("Attempting to probe radio type %s", radio)
219 
220  dev_config = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path})
221  probe_result = await radio.controller.probe(dev_config)
222 
223  if not probe_result:
224  continue
225 
226  # Radio library probing can succeed and return new device settings
227  if isinstance(probe_result, dict):
228  dev_config = probe_result
229 
230  self.radio_typeradio_type = radio
231  self.device_settingsdevice_settings = dev_config
232 
233  repairs.async_delete_blocking_issues(self.hass)
234  return ProbeResult.RADIO_TYPE_DETECTED
235 
237  if await repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware(
238  self.hass, self.device_path
239  ):
240  return ProbeResult.WRONG_FIRMWARE_INSTALLED
241 
242  return ProbeResult.PROBING_FAILED
243 
245  self, *, create_backup: bool = False
246  ) -> zigpy.backups.NetworkBackup | None:
247  """Connect to the radio and load its current network settings."""
248  backup = None
249 
250  async with self.connect_zigpy_appconnect_zigpy_app() as app:
251  await app.connect()
252 
253  # Check if the stick has any settings and load them
254  try:
255  await app.load_network_info()
256  except NetworkNotFormed:
257  pass
258  else:
259  self.current_settingscurrent_settings = zigpy.backups.NetworkBackup(
260  network_info=app.state.network_info,
261  node_info=app.state.node_info,
262  )
263 
264  if create_backup:
265  backup = await app.backups.create_backup()
266 
267  # The list of backups will always exist
268  self.backupsbackups = app.backups.backups.copy()
269  self.backupsbackups.sort(reverse=True, key=lambda b: b.backup_time)
270 
271  return backup
272 
273  async def async_form_network(self) -> None:
274  """Form a brand-new network."""
275  async with self.connect_zigpy_appconnect_zigpy_app() as app:
276  await app.connect()
277  await app.form_network()
278 
279  async def async_reset_adapter(self) -> None:
280  """Reset the current adapter."""
281  async with self.connect_zigpy_appconnect_zigpy_app() as app:
282  await app.connect()
283  await app.reset_network_info()
284 
285  async def async_restore_backup_step_1(self) -> bool:
286  """Prepare restoring backup.
287 
288  Returns True if async_restore_backup_step_2 should be called.
289  """
290  assert self.chosen_backup is not None
291 
292  if self.radio_typeradio_type != RadioType.ezsp:
293  await self.restore_backuprestore_backup(self.chosen_backup)
294  return False
295 
296  # We have no way to partially load network settings if no network is formed
297  if self.current_settingscurrent_settings is None:
298  # Since we are going to be restoring the backup anyways, write it to the
299  # radio without overwriting the IEEE but don't take a backup with these
300  # temporary settings
301  temp_backup = _prevent_overwrite_ezsp_ieee(self.chosen_backup)
302  await self.restore_backuprestore_backup(temp_backup, create_new=False)
303  await self.async_load_network_settingsasync_load_network_settings()
304 
305  assert self.current_settingscurrent_settings is not None
306 
307  metadata = self.current_settingscurrent_settings.network_info.metadata["ezsp"]
308 
309  if (
310  self.current_settingscurrent_settings.node_info.ieee == self.chosen_backup.node_info.ieee
311  or metadata["can_rewrite_custom_eui64"]
312  or not metadata["can_burn_userdata_custom_eui64"]
313  ):
314  # No point in prompting the user if the backup doesn't have a new IEEE
315  # address or if there is no way to overwrite the IEEE address a second time
316  await self.restore_backuprestore_backup(self.chosen_backup)
317 
318  return False
319 
320  return True
321 
322  async def async_restore_backup_step_2(self, overwrite_ieee: bool) -> None:
323  """Restore backup and optionally overwrite IEEE."""
324  assert self.chosen_backup is not None
325 
326  backup = self.chosen_backup
327 
328  if overwrite_ieee:
329  backup = _allow_overwrite_ezsp_ieee(backup)
330 
331  # If the user declined to overwrite the IEEE *and* we wrote the backup to
332  # their empty radio above, restoring it again would be redundant.
333  await self.restore_backuprestore_backup(backup)
334 
335 
337  """Helper class for automatic migration when upgrading the firmware of a radio.
338 
339  This class is currently only intended to be used when changing the firmware on the
340  radio used in the Home Assistant SkyConnect USB stick and the Home Assistant Yellow
341  from Zigbee only firmware to firmware supporting both Zigbee and Thread.
342  """
343 
344  def __init__(
345  self, hass: HomeAssistant, config_entry: config_entries.ConfigEntry
346  ) -> None:
347  """Initialize MigrationHelper instance."""
348  self._config_entry_config_entry = config_entry
349  self._hass_hass = hass
350  self._radio_mgr_radio_mgr = ZhaRadioManager()
351  self._radio_mgr_radio_mgr.hass = hass
352 
353  async def async_initiate_migration(self, data: dict[str, Any]) -> bool:
354  """Initiate ZHA migration.
355 
356  The passed data should contain:
357  - Discovery data identifying the device being firmware updated
358  - Discovery data for connecting to the device after the firmware update is
359  completed.
360 
361  Returns True if async_finish_migration should be called after the firmware
362  update is completed.
363  """
364  migration_data = HARDWARE_MIGRATION_SCHEMA(data)
365 
366  name = migration_data["new_discovery_info"]["name"]
367  new_radio_type = ZhaRadioManager.parse_radio_type(
368  migration_data["new_discovery_info"]["radio_type"]
369  )
370 
371  new_device_settings = SCHEMA_DEVICE(
372  migration_data["new_discovery_info"]["port"]
373  )
374 
375  if "hw" in migration_data["old_discovery_info"]:
376  old_device_path = migration_data["old_discovery_info"]["hw"]["port"]["path"]
377  else: # usb
378  device = migration_data["old_discovery_info"]["usb"].device
379  old_device_path = await self._hass_hass.async_add_executor_job(
380  usb.get_serial_by_id, device
381  )
382 
383  if self._config_entry_config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] != old_device_path:
384  # ZHA is using another radio, do nothing
385  return False
386 
387  # OperationNotAllowed: ZHA is not running
388  with suppress(config_entries.OperationNotAllowed):
389  await self._hass_hass.config_entries.async_unload(self._config_entry_config_entry.entry_id)
390 
391  # Temporarily connect to the old radio to read its settings
392  config_entry_data = self._config_entry_config_entry.data
393  old_radio_mgr = ZhaRadioManager()
394  old_radio_mgr.hass = self._hass_hass
395  old_radio_mgr.device_path = config_entry_data[CONF_DEVICE][CONF_DEVICE_PATH]
396  old_radio_mgr.device_settings = config_entry_data[CONF_DEVICE]
397  old_radio_mgr.radio_type = RadioType[config_entry_data[CONF_RADIO_TYPE]]
398 
399  for retry in range(BACKUP_RETRIES):
400  try:
401  backup = await old_radio_mgr.async_load_network_settings(
402  create_backup=True
403  )
404  break
405  except OSError as err:
406  if retry >= BACKUP_RETRIES - 1:
407  raise
408 
409  _LOGGER.debug(
410  "Failed to create backup %r, retrying in %s seconds",
411  err,
412  RETRY_DELAY_S,
413  )
414 
415  await asyncio.sleep(RETRY_DELAY_S)
416 
417  # Then configure the radio manager for the new radio to use the new settings
418  self._radio_mgr_radio_mgr.chosen_backup = backup
419  self._radio_mgr_radio_mgr.radio_type = new_radio_type
420  self._radio_mgr_radio_mgr.device_path = new_device_settings[CONF_DEVICE_PATH]
421  self._radio_mgr_radio_mgr.device_settings = new_device_settings
422  device_settings = self._radio_mgr_radio_mgr.device_settings.copy() # type: ignore[union-attr]
423 
424  # Update the config entry settings
425  self._hass_hass.config_entries.async_update_entry(
426  entry=self._config_entry_config_entry,
427  data={
428  CONF_DEVICE: device_settings,
429  CONF_RADIO_TYPE: self._radio_mgr_radio_mgr.radio_type.name,
430  },
431  options=self._config_entry_config_entry.options,
432  title=name,
433  )
434  return True
435 
436  async def async_finish_migration(self) -> None:
437  """Finish ZHA migration.
438 
439  Throws an exception if the migration did not succeed.
440  """
441  # Restore the backup, permanently overwriting the device IEEE address
442  for retry in range(MIGRATION_RETRIES):
443  try:
444  if await self._radio_mgr_radio_mgr.async_restore_backup_step_1():
445  await self._radio_mgr_radio_mgr.async_restore_backup_step_2(True)
446 
447  break
448  except OSError as err:
449  if retry >= MIGRATION_RETRIES - 1:
450  raise
451 
452  _LOGGER.debug(
453  "Failed to restore backup %r, retrying in %s seconds",
454  err,
455  RETRY_DELAY_S,
456  )
457 
458  await asyncio.sleep(RETRY_DELAY_S)
459 
460  _LOGGER.debug("Restored backup after %s retries", retry)
461 
462  # Launch ZHA again
463  # OperationNotAllowed: ZHA is not unloaded
464  with suppress(config_entries.OperationNotAllowed):
465  await self._hass_hass.config_entries.async_setup(self._config_entry_config_entry.entry_id)
None __init__(self, HomeAssistant hass, config_entries.ConfigEntry config_entry)
AsyncIterator[ControllerApplication] connect_zigpy_app(self)
None restore_backup(self, zigpy.backups.NetworkBackup backup, **Any kwargs)
None async_restore_backup_step_2(self, bool overwrite_ieee)
Self from_config_entry(cls, HomeAssistant hass, config_entries.ConfigEntry config_entry)
zigpy.backups.NetworkBackup|None async_load_network_settings(self, *bool create_backup=False)
HAZHAData get_zha_data(HomeAssistant hass)
Definition: helpers.py:1020
zigpy.backups.NetworkBackup _prevent_overwrite_ezsp_ieee(zigpy.backups.NetworkBackup backup)
zigpy.backups.NetworkBackup _allow_overwrite_ezsp_ieee(zigpy.backups.NetworkBackup backup)