Compare commits

...

18 Commits

Author SHA1 Message Date
Stefan Agner 90e8ed0210 Use direct key access for the Multi-PAN repair issue data payload
`async_create_multi_pan_migration_issue` always populates the issue
data with `entry_id`, so reading it back with `dict.get` masks contract
violations rather than surfacing them. Switch to `data["entry_id"]` in
both Yellow and SkyConnect's `async_create_fix_flow`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:22:55 +02:00
Stefan Agner fd049891ff Catch specific firmware-flash errors in Multi-PAN repair flow
Previously the flow caught a bare `Exception` and logged a stack trace,
which mixes intentional flow-abort signals with unexpected programmer
errors and is noisier than other steps in this flow.

Wrap the network-side fetch (`async_update_data`, `async_fetch_firmware`,
firmware lookup) in a focused handler that converts realistic failures
(`StopIteration`, `TimeoutError`, `ClientError`, `ManifestMissing`,
`ValueError`) into `HomeAssistantError`, mirroring `firmware_config_flow`.
`async_flash_silabs_firmware` already raises `HomeAssistantError`, so
the outer handler can now catch only that and log a single line. Update
the affected tests to use the matching exception types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:22:06 +02:00
Stefan Agner e1b2de20d9 Mock the firmware flashing context in Multi-PAN options-flow tests
The Multi-PAN uninstall flow now wraps the Zigbee firmware flash in
`async_firmware_flashing_context`, which calls into
`homeassistant_hardware`'s firmware-update registry. The options-flow
tests don't load that integration, so the context manager raised
`KeyError('homeassistant_hardware')` and the flow ended in
`firmware_flash_failed` instead of completing. Patch the context to
no-op alongside the existing `async_flash_silabs_firmware` mock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:54:40 +02:00
Stefan Agner ab52d02da9 Initialize Multi-PAN repair flows via the explicit options-flow base
`super().__init__(...)` works here because Python's MRO walks past the
intervening `RepairsFlow` mixin (which has no `__init__`) and lands on
the options-flow base, but it's not obvious from reading the code —
Copilot already misread it as a TypeError. Calling the named
options-flow class directly makes intent explicit: the diamond's
options-flow side is the one that actually consumes `config_entry`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:51:34 +02:00
Stefan Agner 98be2356ca Lock the device while flashing Zigbee firmware in Multi-PAN repair flow
`async_flash_silabs_firmware` is documented as needing to run within a
firmware update context. Both `firmware_config_flow` and the hardware
update entity wrap their flash calls with
`async_firmware_flashing_context`, which signals to other integrations
(notably ZHA) that the hardware is in use and pauses any owners that
would otherwise reclaim the serial device mid-flash. The Multi-PAN
repair flow flashed the radio without that context, so a ZHA reload
after the multiprotocol add-on stopped could race the flash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:43:22 +02:00
Stefan Agner 9eddc58754 Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 17:38:45 +02:00
Stefan Agner e709189dab Add Multi-PAN progress translations to options flow
The options flow base class emits `install_zigbee_firmware` and
`uninstall_multiprotocol_addon` as progress actions, but neither
Yellow nor SkyConnect defined matching `options.progress.*` keys, so a
direct options-flow run would render the dialog with unlocalized text.
Add canonical translations under `silabs_multiprotocol_hardware.options
.progress` and reference them from both `options.progress` and
`fix_flow.progress` in Yellow and SkyConnect (replacing the inline
literals previously committed for the fix flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:32:16 +02:00
Stefan Agner b2ca7f4eef Handle Supervisor errors when checking Multi-PAN add-on usage
`multi_pan_addon_using_device()` calls `async_get_addon_info()`, which
can raise `AddonError` (a `HomeAssistantError`). Both Yellow and
SkyConnect now invoke this unconditionally during `async_setup_entry`,
so a transient Supervisor failure would crash setup with an uncaught
exception. Wrap the calls and raise `ConfigEntryNotReady` instead, so
setup retries cleanly — matching the existing pattern for
`check_multi_pan_addon`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:26:51 +02:00
Stefan Agner 0a14e93cb3 Fix fw_install_failed abort placeholders for Multi-PAN flow
The shared `firmware_picker` `fw_install_failed` translation expects
`{firmware_name}`, not `{hardware_name}`, so the placeholder was never
substituted. Pass `firmware_name="Zigbee"` to align with how the
firmware_picker flow invokes the same translation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:26:13 +02:00
Stefan Agner 352e89812f Update Multi-PAN repair flow translations to match new flow
The Multi-PAN repair issue flow now flashes Zigbee firmware directly
instead of starting the flasher add-on. Replace the dead
`progress.start_flasher_addon` key with `progress.install_zigbee_firmware`
(using `{hardware_name}`), and add the `abort.fw_install_failed` alias
since the flow can now abort with that reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:25:25 +02:00
Stefan Agner 2f1730ed51 Fix repair flow to render uninstall form on first invocation
The repair flow init data is the issue context, not user form input. Passing
it to async_step_uninstall_addon caused a KeyError: 'disable_multi_pan'.
Drop the user_input forwarding so the uninstall confirmation form is rendered
on first invocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 15:13:49 +02:00
Stefan Agner 110ba33e43 Merge branch 'dev' into create-repair-to-remove-multiprotocol 2026-04-28 14:45:54 +02:00
Stefan Agner 9d96626e93 Update tests for direct firmware flashing flow
Update all tests that referenced the old flasher addon steps to match
the new direct flashing flow (uninstall multiprotocol addon → flash
zigbee firmware → flashing complete).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:02:43 +02:00
Stefan Agner 26df3f1966 Add repairs dependency to homeassistant_hardware manifest
The repair_helpers module imports RepairsFlow from the repairs
integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:28:35 +02:00
Stefan Agner dc4f4ad5fe Replace flasher addon with direct firmware flashing
Use universal-silabs-flasher directly instead of the flasher addon to
flash Zigbee firmware during multi-PAN migration. This eliminates the
dependency on the flasher addon and avoids issues with stale addon
config (e.g. firmware_url being set from a previous use).

The new flow: ZHA backup via multiprotocol socket, uninstall
multiprotocol addon, download Zigbee firmware from Nabu Casa releases,
flash directly via universal-silabs-flasher, restore ZHA backup.

Also moves WaitingAddonManager from silabs_multiprotocol_addon to util
to break a circular import and allow top-level imports of
async_flash_silabs_firmware and ApplicationType.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:35:39 +02:00
Stefan Agner 523f5dac48 Rename repairs.py to repair_helpers.py to avoid platform discovery
The repairs platform discovery finds any module named repairs.py and
tries to register it. Since homeassistant_hardware/repairs.py only
contains shared helpers and the mixin (no async_create_fix_flow), the
discovery fails with "Invalid repairs platform". Rename to
repair_helpers.py so it is not discovered as a platform.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:55:13 +02:00
Stefan Agner 19aecf2f1c Detect multi-PAN via addon state, not just firmware field
The firmware field in the config entry can be wrong (e.g. guessed as
"spinel" instead of "cpc") since it is only set once during migration.
Check the actual multiprotocol addon state via multi_pan_addon_using_device
in addition to the firmware field to reliably detect multi-PAN usage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:49:07 +02:00
Stefan Agner 6170156408 Add repair to migrate away from multiprotocol/Multi-PAN
Create a fixable repair issue when Yellow or SkyConnect is running
multiprotocol (CPC) firmware. The repair flow reuses the existing
options flow uninstall steps to revert the radio to Zigbee-only
firmware and migrate ZHA automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:49:07 +02:00
20 changed files with 917 additions and 400 deletions
@@ -3,7 +3,7 @@
"name": "Home Assistant Hardware",
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["usb"],
"dependencies": ["repairs", "usb"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
@@ -0,0 +1,74 @@
"""Repairs for the Home Assistant Hardware integration."""
from __future__ import annotations
from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration"
@callback
def _multi_pan_issue_id(config_entry: ConfigEntry) -> str:
"""Return the issue id for the multi-PAN migration issue of an entry."""
return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}"
@callback
def async_create_multi_pan_migration_issue(
hass: HomeAssistant,
domain: str,
config_entry: ConfigEntry,
) -> None:
"""Create a repair issue to guide migration away from Multi-PAN."""
ir.async_create_issue(
hass,
domain=domain,
issue_id=_multi_pan_issue_id(config_entry),
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_MULTI_PAN_MIGRATION,
translation_placeholders={"hardware_name": config_entry.title},
data={"entry_id": config_entry.entry_id},
)
@callback
def async_delete_multi_pan_migration_issue(
hass: HomeAssistant,
domain: str,
config_entry: ConfigEntry,
) -> None:
"""Delete the multi-PAN migration repair issue for this entry."""
ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry))
class MultiPanMigrationRepairFlow(RepairsFlow):
"""Reuse the multi-PAN options flow uninstall steps as a repair flow.
Subclass this together with the hardware-specific
``MultiPanOptionsFlowHandler`` in each hardware integration's repairs
module.
The repair flow runs in the repairs flow manager where ``self.handler``
is the integration domain rather than the hardware config entry id, so
the ``config_entry`` accessor of ``OptionsFlow`` must be overridden.
"""
_repair_config_entry: ConfigEntry
@property
def config_entry(self) -> ConfigEntry:
"""Return the hardware config entry to migrate."""
return self._repair_config_entry
async def _async_step_start_migration(self) -> ConfigFlowResult:
"""Jump straight into the uninstall step of the migration flow.
The repair flow's init data is the issue context, not user form input,
so pass None to render the uninstall confirmation form.
"""
return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return]
@@ -8,6 +8,8 @@ import dataclasses
import logging
from typing import Any, Protocol
from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
import voluptuous as vol
import yarl
@@ -27,6 +29,7 @@ from homeassistant.config_entries import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
@@ -39,15 +42,18 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG
from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
from .util import (
ApplicationType,
WaitingAddonManager,
async_firmware_flashing_context,
async_flash_silabs_firmware,
)
_LOGGER = logging.getLogger(__name__)
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
DATA_FLASHER_ADDON_MANAGER = "silabs_flasher"
ADDON_STATE_POLL_INTERVAL = 3
ADDON_INFO_POLL_TIMEOUT = 15 * 60
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
CONF_ADDON_DEVICE = "device"
@@ -73,53 +79,6 @@ async def get_multiprotocol_addon_manager(
return manager
class WaitingAddonManager(AddonManager):
"""Addon manager which supports waiting operations for managing an addon."""
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
"""Poll an addon's info until it is in a specific state."""
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
while True:
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
if info is not None and info.state in states:
break
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
async def async_start_addon_waiting(self) -> None:
"""Start an add-on."""
await self.async_schedule_start_addon()
await self.async_wait_until_addon_state(AddonState.RUNNING)
async def async_install_addon_waiting(self) -> None:
"""Install an add-on."""
await self.async_schedule_install_addon()
await self.async_wait_until_addon_state(
AddonState.RUNNING,
AddonState.NOT_RUNNING,
)
async def async_uninstall_addon_waiting(self) -> None:
"""Uninstall an add-on."""
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state == AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
class MultiprotocolAddonManager(WaitingAddonManager):
"""Silicon Labs Multiprotocol add-on manager."""
@@ -267,18 +226,6 @@ class MultipanProtocol(Protocol):
"""
@singleton(DATA_FLASHER_ADDON_MANAGER)
@callback
def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
"""Get the flasher add-on manager."""
return WaitingAddonManager(
hass,
LOGGER,
"Silicon Labs Flasher",
SILABS_FLASHER_ADDON_SLUG,
)
@dataclasses.dataclass
class SerialPortSettings:
"""Serial port settings."""
@@ -341,6 +288,19 @@ class OptionsFlowHandler(OptionsFlow, ABC):
def _zha_name(self) -> str:
"""Return the ZHA name."""
@abstractmethod
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
@abstractmethod
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp')."""
@property
@abstractmethod
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
@property
def flow_manager(self) -> OptionsFlowManager:
"""Return the correct flow manager."""
@@ -688,61 +648,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
async def async_step_firmware_revert(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install the flasher addon, if necessary."""
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_flasher_addon()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_configure_flasher_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="addon_already_running",
description_placeholders={"addon_name": flasher_manager.addon_name},
)
async def async_step_install_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing flasher addon."""
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
_LOGGER.debug("Flasher addon state: %s", addon_info)
if not self.install_task:
self.install_task = self.hass.async_create_task(
flasher_manager.async_install_addon_waiting(),
"SiLabs Flasher addon install",
eager_start=False,
)
if not self.install_task.done():
return self.async_show_progress(
step_id="install_flasher_addon",
progress_action="install_addon",
description_placeholders={"addon_name": flasher_manager.addon_name},
progress_task=self.install_task,
)
try:
await self.install_task
except AddonError as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="install_failed")
finally:
self.install_task = None
return self.async_show_progress_done(next_step_id="configure_flasher_addon")
async def async_step_configure_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Perform initial backup and reconfigure ZHA."""
"""Initiate ZHA backup and start multiprotocol addon uninstall."""
# pylint: disable=hass-component-root-import
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
@@ -784,17 +690,6 @@ class OptionsFlowHandler(OptionsFlow, ABC):
_LOGGER.exception("Unexpected exception during ZHA migration")
raise AbortFlow("zha_migration_failed") from err
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
new_addon_config = {
**addon_info.options,
"device": new_settings.device,
"flow_control": new_settings.flow_control,
}
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, flasher_manager)
return await self.async_step_uninstall_multiprotocol_addon()
async def async_step_uninstall_multiprotocol_addon(
@@ -823,62 +718,90 @@ class OptionsFlowHandler(OptionsFlow, ABC):
finally:
self.stop_task = None
return self.async_show_progress_done(next_step_id="start_flasher_addon")
return self.async_show_progress_done(next_step_id="install_zigbee_firmware")
async def async_step_start_flasher_addon(
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Start Silicon Labs Flasher add-on."""
flasher_manager = get_flasher_addon_manager(self.hass)
"""Flash Zigbee firmware directly onto the radio."""
if not self.install_task:
if not self.start_task:
async def _flash_firmware() -> None:
serial_port_settings = await self._async_serial_port_settings()
device = serial_port_settings.device
async def start_and_wait_until_done() -> None:
await flasher_manager.async_start_addon_waiting()
# Now that the addon is running, wait for it to finish
await flasher_manager.async_wait_until_addon_state(
AddonState.NOT_RUNNING
)
# For the duration of firmware flashing, hint to other integrations
# (i.e. ZHA) that the hardware is in use and should not be accessed.
async with async_firmware_flashing_context(self.hass, device, DOMAIN):
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(self._firmware_update_url(), session)
self.start_task = self.hass.async_create_task(
start_and_wait_until_done(), eager_start=False
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw
for fw in manifest.firmwares
if fw.filename.startswith(self._zigbee_firmware_type())
)
fw_data = await client.async_fetch_firmware(fw_manifest)
except (
StopIteration,
TimeoutError,
ClientError,
ManifestMissing,
ValueError,
) as err:
raise HomeAssistantError(
"Failed to fetch Zigbee firmware"
) from err
await async_flash_silabs_firmware(
hass=self.hass,
device=device,
fw_data=fw_data,
flasher_cls=self._flasher_cls,
expected_installed_firmware_type=ApplicationType.EZSP,
)
self.install_task = self.hass.async_create_task(
_flash_firmware(),
"Flash Zigbee firmware",
eager_start=False,
)
if not self.start_task.done():
if not self.install_task.done():
return self.async_show_progress(
step_id="start_flasher_addon",
progress_action="start_flasher_addon",
description_placeholders={"addon_name": flasher_manager.addon_name},
progress_task=self.start_task,
step_id="install_zigbee_firmware",
progress_action="install_zigbee_firmware",
description_placeholders={
"hardware_name": self._hardware_name(),
},
progress_task=self.install_task,
)
try:
await self.start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="flasher_failed")
await self.install_task
except HomeAssistantError as err:
_LOGGER.error("Failed to flash Zigbee firmware: %s", err)
return self.async_show_progress_done(next_step_id="firmware_flash_failed")
finally:
self.start_task = None
self.install_task = None
return self.async_show_progress_done(next_step_id="flashing_complete")
async def async_step_flasher_failed(
async def async_step_firmware_flash_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Flasher add-on start failed."""
flasher_manager = get_flasher_addon_manager(self.hass)
"""Firmware flashing failed."""
return self.async_abort(
reason="addon_start_failed",
description_placeholders={"addon_name": flasher_manager.addon_name},
reason="fw_install_failed",
description_placeholders={"firmware_name": "Zigbee"},
)
async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Finish flashing and update the config entry."""
flasher_manager = get_flasher_addon_manager(self.hass)
await flasher_manager.async_uninstall_addon_waiting()
# Finish ZHA migration if needed
if self._zha_migration_mgr:
try:
@@ -102,7 +102,9 @@
},
"progress": {
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
"install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.",
"uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled."
},
"step": {
"addon_installed_other_device": {
@@ -38,13 +38,59 @@ from .const import (
ZIGBEE_FLASHER_ADDON_SLUG,
)
from .helpers import async_firmware_update_context
from .silabs_multiprotocol_addon import (
WaitingAddonManager,
get_multiprotocol_addon_manager,
)
_LOGGER = logging.getLogger(__name__)
ADDON_STATE_POLL_INTERVAL = 3
ADDON_INFO_POLL_TIMEOUT = 15 * 60
class WaitingAddonManager(AddonManager):
"""Addon manager which supports waiting operations for managing an addon."""
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
"""Poll an addon's info until it is in a specific state."""
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
while True:
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
if info is not None and info.state in states:
break
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
async def async_start_addon_waiting(self) -> None:
"""Start an add-on."""
await self.async_schedule_start_addon()
await self.async_wait_until_addon_state(AddonState.RUNNING)
async def async_install_addon_waiting(self) -> None:
"""Install an add-on."""
await self.async_schedule_install_addon()
await self.async_wait_until_addon_state(
AddonState.RUNNING,
AddonState.NOT_RUNNING,
)
async def async_uninstall_addon_waiting(self) -> None:
"""Uninstall an add-on."""
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state == AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
class ApplicationType(StrEnum):
"""Application type running on a device."""
@@ -280,6 +326,11 @@ async def guess_hardware_owners(
assert otbr_addon_fw_info is not None
device_guesses[otbr_path].append(otbr_addon_fw_info)
# Lazy import to avoid circular dependency
from .silabs_multiprotocol_addon import ( # noqa: PLC0415
get_multiprotocol_addon_manager,
)
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
try:
@@ -9,7 +9,17 @@ import os.path
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
from homeassistant.components.homeassistant_hardware.repair_helpers import (
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
multi_pan_addon_using_device,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
guess_firmware_info,
)
from homeassistant.components.usb import (
USBDevice,
async_register_port_event_callback,
@@ -94,6 +104,18 @@ async def async_setup_entry(
translation_key="device_disconnected",
)
uses_multi_pan = ApplicationType(entry.data[FIRMWARE]) is ApplicationType.CPC
if not uses_multi_pan:
try:
uses_multi_pan = await multi_pan_addon_using_device(hass, device_path)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if uses_multi_pan:
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
else:
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
# Create and store the firmware update coordinator in runtime_data
session = async_get_clientsession(hass)
coordinator = FirmwareUpdateCoordinator(
@@ -254,6 +254,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
"""Return the name of the hardware."""
return self._hw_variant.full_name
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return NABU_CASA_FIRMWARE_RELEASES_URL
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "skyconnect_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return Zbt1Flasher # type: ignore[no-any-return]
async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -0,0 +1,46 @@
"""Repairs for the Home Assistant SkyConnect integration."""
from __future__ import annotations
from typing import Any, cast
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
MultiPanMigrationRepairFlow,
)
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.core import HomeAssistant
from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler
class SkyConnectMultiPanMigrationRepairFlow(
MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler
):
"""Multi-PAN migration repair flow for Home Assistant SkyConnect."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize the repair flow."""
HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry)
self._repair_config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Jump straight into the uninstall step."""
return await self._async_step_start_migration()
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a fix flow for a SkyConnect repair issue."""
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
return SkyConnectMultiPanMigrationRepairFlow(entry)
return ConfirmRepairFlow()
@@ -106,6 +106,37 @@
"message": "The device is not plugged in"
}
},
"issues": {
"multi_pan_migration": {
"fix_flow": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"uninstall_addon": {
"data": {
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
},
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
}
},
"title": "Multiprotocol support is deprecated"
}
},
"options": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
@@ -130,8 +161,10 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"addon_installed_other_device": {
@@ -9,8 +9,13 @@ from homeassistant.components.hassio import get_os_info
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.homeassistant_hardware.repair_helpers import (
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
check_multi_pan_addon,
multi_pan_addon_using_device,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
@@ -29,6 +34,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from .const import (
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
@@ -72,12 +78,21 @@ async def async_setup_entry(
firmware = ApplicationType(entry.data[FIRMWARE])
# Auto start the multiprotocol addon if it is in use
try:
multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if firmware is ApplicationType.CPC:
try:
await check_multi_pan_addon(hass)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if firmware is ApplicationType.CPC or multipan_using_device:
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
else:
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
if firmware is ApplicationType.EZSP:
discovery_flow.async_create_flow(
hass,
@@ -321,6 +321,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
"""Return the name of the hardware."""
return BOARD_NAME
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return NABU_CASA_FIRMWARE_RELEASES_URL
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "yellow_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return YellowFlasher # type: ignore[no-any-return]
async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -0,0 +1,44 @@
"""Repairs for the Home Assistant Yellow integration."""
from __future__ import annotations
from typing import cast
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
MultiPanMigrationRepairFlow,
)
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.core import HomeAssistant
from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler
class YellowMultiPanMigrationRepairFlow(
MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler
):
"""Multi-PAN migration repair flow for Home Assistant Yellow."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the repair flow."""
HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry)
self._repair_config_entry = config_entry
async def async_step_main_menu(self, _: None = None) -> ConfigFlowResult:
"""Jump straight into the uninstall step."""
return await self._async_step_start_migration()
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a fix flow for a Yellow repair issue."""
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
return YellowMultiPanMigrationRepairFlow(hass, entry)
return ConfirmRepairFlow()
@@ -11,6 +11,37 @@
}
}
},
"issues": {
"multi_pan_migration": {
"fix_flow": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"uninstall_addon": {
"data": {
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
},
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
}
},
"title": "Multiprotocol support is deprecated"
}
},
"options": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
@@ -37,8 +68,10 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"addon_installed_other_device": {
@@ -0,0 +1,44 @@
"""Tests for the homeassistant_hardware repairs helpers."""
from __future__ import annotations
import pytest
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from tests.common import MockConfigEntry
TEST_DOMAIN = "test_hardware"
@pytest.fixture
def ignore_translations_for_mock_domains() -> str:
"""Ignore translation check for the fake test_hardware domain."""
return TEST_DOMAIN
async def test_create_and_delete_multi_pan_migration_issue(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test the helpers create and delete the migration issue per entry."""
entry = MockConfigEntry(domain=TEST_DOMAIN, title="Test HW", data={})
entry.add_to_hass(hass)
async_create_multi_pan_migration_issue(hass, TEST_DOMAIN, entry)
issue_id = f"{ISSUE_MULTI_PAN_MIGRATION}_{entry.entry_id}"
issue = issue_registry.async_get_issue(domain=TEST_DOMAIN, issue_id=issue_id)
assert issue is not None
assert issue.translation_key == ISSUE_MULTI_PAN_MIGRATION
assert issue.translation_placeholders == {"hardware_name": "Test HW"}
assert issue.data == {"entry_id": entry.entry_id}
assert issue.is_fixable
assert issue.severity is ir.IssueSeverity.WARNING
async_delete_multi_pan_migration_issue(hass, TEST_DOMAIN, entry)
assert issue_registry.async_get_issue(domain=TEST_DOMAIN, issue_id=issue_id) is None
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, Mock, patch
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import AddonsOptions
from aiohttp import ClientError
import pytest
from homeassistant.components.hassio import AddonError, AddonInfo, AddonState, HassIO
@@ -98,6 +99,19 @@ class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler):
"""Return the name of the hardware."""
return "Test"
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return "https://example.com/firmware"
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "test_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return Mock
@pytest.fixture(autouse=True)
def config_flow_handler(
@@ -118,6 +132,31 @@ def options_flow_poll_addon_state() -> Generator[None]:
yield
@pytest.fixture
def mock_firmware_client() -> Generator[tuple[AsyncMock, AsyncMock]]:
"""Fixture to mock FirmwareUpdateClient and async_flash_silabs_firmware."""
mock_fw_manifest = Mock()
mock_fw_manifest.filename = "test_zigbee_ncp_7.4.4.0.gbl"
mock_fw_client = AsyncMock()
mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest])
mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware"
with (
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient",
return_value=mock_fw_client,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_firmware_flashing_context"
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware",
new_callable=AsyncMock,
) as mock_flash,
):
yield mock_fw_client, mock_flash
@pytest.fixture(autouse=True)
def hassio_integration(hass: HomeAssistant) -> Generator[None]:
"""Fixture to mock the `hassio` integration."""
@@ -174,7 +213,7 @@ def get_suggested(schema, key):
@patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.ADDON_STATE_POLL_INTERVAL",
"homeassistant.components.homeassistant_hardware.util.ADDON_STATE_POLL_INTERVAL",
0,
)
@pytest.mark.usefixtures(
@@ -633,12 +672,10 @@ async def test_option_flow_addon_installed_same_device_uninstall(
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
mock_firmware_client: tuple[AsyncMock, AsyncMock],
) -> None:
"""Test uninstalling the multi pan addon."""
@@ -675,21 +712,10 @@ async def test_option_flow_addon_installed_same_device_uninstall(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "uninstall_addon"
# Make sure the flasher addon is installed
addon_store_info.return_value.installed = False
addon_store_info.return_Value.available = True
result = await hass.config_entries.options.async_configure(
result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "install_flasher_addon"
assert result["progress_action"] == "install_addon"
await hass.async_block_till_done()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_multiprotocol_addon"
assert result["progress_action"] == "uninstall_multiprotocol_addon"
@@ -699,12 +725,10 @@ async def test_option_flow_addon_installed_same_device_uninstall(
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_flasher_addon"
assert result["progress_action"] == "start_flasher_addon"
assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"}
assert result["step_id"] == "install_zigbee_firmware"
assert result["progress_action"] == "install_zigbee_firmware"
await hass.async_block_till_done()
install_addon.assert_called_once_with("core_silabs_flasher")
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -726,11 +750,6 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
) -> None:
"""Test uninstalling the multi pan addon."""
@@ -764,19 +783,15 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"])
async def test_option_flow_flasher_already_running_failure(
async def test_option_flow_firmware_flash_failure(
hass: HomeAssistant,
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
) -> None:
"""Test uninstalling the multi pan addon but with the flasher addon running."""
"""Test uninstalling the multi pan addon, case where firmware flash fails."""
addon_info.return_value.options["device"] = "/dev/ttyTEST123"
@@ -800,162 +815,60 @@ async def test_option_flow_flasher_already_running_failure(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "uninstall_addon"
# The flasher addon is already installed and running, this is bad
addon_store_info.return_value.installed = True
addon_info.return_value.state = "started"
mock_fw_manifest = Mock()
mock_fw_manifest.filename = "test_zigbee_ncp_7.4.4.0.gbl"
mock_fw_client = AsyncMock()
mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest])
mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_already_running"
with (
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient",
return_value=mock_fw_client,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_firmware_flashing_context"
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware",
new_callable=AsyncMock,
side_effect=HomeAssistantError("Flash failed"),
),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_multiprotocol_addon"
assert result["progress_action"] == "uninstall_multiprotocol_addon"
async def test_option_flow_addon_installed_same_device_flasher_already_installed(
hass: HomeAssistant,
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
) -> None:
"""Test uninstalling the multi pan addon."""
await hass.async_block_till_done()
uninstall_addon.assert_called_once_with("core_silabs_multiprotocol")
addon_info.return_value.options["device"] = "/dev/ttyTEST123"
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "install_zigbee_firmware"
assert result["progress_action"] == "install_zigbee_firmware"
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=TEST_DOMAIN,
options={},
title="Test HW",
)
config_entry.add_to_hass(hass)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "addon_menu"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "uninstall_addon"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "uninstall_addon"
addon_store_info.return_value.installed = True
addon_store_info.return_value.available = True
result = await hass.config_entries.options.async_configure(
result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_multiprotocol_addon"
assert result["progress_action"] == "uninstall_multiprotocol_addon"
await hass.async_block_till_done()
uninstall_addon.assert_called_once_with("core_silabs_multiprotocol")
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_flasher_addon"
assert result["progress_action"] == "start_flasher_addon"
assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"}
addon_store_info.return_value.installed = True
addon_store_info.return_value.available = True
await hass.async_block_till_done()
install_addon.assert_not_called()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "fw_install_failed"
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"])
async def test_option_flow_flasher_install_failure(
async def test_option_flow_zigbee_firmware_fetch_failure(
hass: HomeAssistant,
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
) -> None:
"""Test uninstalling the multi pan addon, case where flasher addon fails."""
addon_info.return_value.options["device"] = "/dev/ttyTEST123"
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=TEST_DOMAIN,
options={},
title="Test HW",
)
config_entry.add_to_hass(hass)
zha_config_entry = MockConfigEntry(
data={
"device": {"path": "socket://core-silabs-multiprotocol:9999"},
"radio_type": "ezsp",
},
domain=ZHA_DOMAIN,
options={},
title="Test Multiprotocol",
)
zha_config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "addon_menu"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "uninstall_addon"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "uninstall_addon"
addon_store_info.return_value.installed = False
addon_store_info.return_value.available = True
install_addon.side_effect = [AddonError()]
result = await hass.config_entries.options.async_configure(
result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "install_flasher_addon"
assert result["progress_action"] == "install_addon"
await hass.async_block_till_done()
install_addon.assert_called_once_with("core_silabs_flasher")
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_install_failed"
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"])
async def test_option_flow_flasher_addon_flash_failure(
hass: HomeAssistant,
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
) -> None:
"""Test where flasher addon fails to flash Zigbee firmware."""
"""Test where fetching Zigbee firmware fails."""
addon_info.return_value.options["device"] = "/dev/ttyTEST123"
@@ -979,30 +892,39 @@ async def test_option_flow_flasher_addon_flash_failure(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "uninstall_addon"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_multiprotocol_addon"
assert result["progress_action"] == "uninstall_multiprotocol_addon"
mock_fw_client = AsyncMock()
mock_fw_client.async_update_data.side_effect = ClientError("Network error")
start_addon.side_effect = SupervisorError("Boom")
with (
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient",
return_value=mock_fw_client,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_firmware_flashing_context"
),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_multiprotocol_addon"
assert result["progress_action"] == "uninstall_multiprotocol_addon"
await hass.async_block_till_done()
uninstall_addon.assert_called_once_with("core_silabs_multiprotocol")
await hass.async_block_till_done()
uninstall_addon.assert_called_once_with("core_silabs_multiprotocol")
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_flasher_addon"
assert result["progress_action"] == "start_flasher_addon"
assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"}
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "install_zigbee_firmware"
assert result["progress_action"] == "install_zigbee_firmware"
await hass.async_block_till_done()
await hass.async_block_till_done()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_start_failed"
assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher"
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "fw_install_failed"
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"])
@@ -1016,11 +938,6 @@ async def test_option_flow_uninstall_migration_initiate_failure(
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
) -> None:
"""Test uninstalling the multi pan addon, case where ZHA migration init fails."""
@@ -1078,14 +995,11 @@ async def test_option_flow_uninstall_migration_finish_failure(
addon_info,
addon_store_info,
addon_installed,
install_addon,
start_addon,
stop_addon,
uninstall_addon,
set_addon_options,
options_flow_poll_addon_state,
mock_firmware_client: tuple[AsyncMock, AsyncMock],
) -> None:
"""Test uninstalling the multi pan addon, case where ZHA migration init fails."""
"""Test uninstalling the multi pan addon, case where ZHA migration finish fails."""
addon_info.return_value.options["device"] = "/dev/ttyTEST123"
@@ -1129,9 +1043,8 @@ async def test_option_flow_uninstall_migration_finish_failure(
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_flasher_addon"
assert result["progress_action"] == "start_flasher_addon"
assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"}
assert result["step_id"] == "install_zigbee_firmware"
assert result["progress_action"] == "install_zigbee_firmware"
await hass.async_block_till_done()
@@ -219,7 +219,7 @@ async def test_guess_hardware_owners_z2m(
return_value=None,
),
patch(
"homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager",
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager",
return_value=multipan_addon_manager,
),
patch(
@@ -290,7 +290,7 @@ async def test_guess_hardware_owners_otbr(hass: HomeAssistant) -> None:
return_value=otbr_addon_fw_info,
),
patch(
"homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager",
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager",
return_value=multipan_addon_manager,
),
patch(
@@ -334,7 +334,7 @@ async def test_guess_hardware_owners_multipan(hass: HomeAssistant) -> None:
return_value=None,
),
patch(
"homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager",
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager",
return_value=multipan_addon_manager,
),
patch(
@@ -18,7 +18,6 @@ from homeassistant.components.homeassistant_hardware.helpers import (
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
CONF_DISABLE_MULTI_PAN,
get_flasher_addon_manager,
get_multiprotocol_addon_manager,
)
from homeassistant.components.homeassistant_hardware.util import (
@@ -384,15 +383,11 @@ async def test_options_flow_multipan_uninstall(
version="1.0.0",
)
mock_flasher_manager = Mock(spec_set=get_flasher_addon_manager(hass))
mock_flasher_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={},
state=AddonState.NOT_RUNNING,
update_available=False,
version="1.0.0",
)
mock_fw_manifest = Mock()
mock_fw_manifest.filename = "skyconnect_zigbee_ncp_7.4.4.0.gbl"
mock_fw_client = AsyncMock()
mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest])
mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware"
with (
patch(
@@ -400,8 +395,12 @@ async def test_options_flow_multipan_uninstall(
return_value=mock_multipan_manager,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_flasher_addon_manager",
return_value=mock_flasher_manager,
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient",
return_value=mock_fw_client,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio",
@@ -424,11 +423,15 @@ async def test_options_flow_multipan_uninstall(
result["flow_id"], user_input={CONF_DISABLE_MULTI_PAN: True}
)
# Finish the flow
# Uninstall multiprotocol addon
result = await hass.config_entries.options.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
# Flash zigbee firmware
result = await hass.config_entries.options.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
# Flashing complete
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -5,6 +5,9 @@ from unittest.mock import patch
import pytest
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
@@ -22,6 +25,7 @@ from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -291,3 +295,126 @@ async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None:
untouched_bad_entry = hass.config_entries.async_get_entry(bad_entry.entry_id)
assert untouched_bad_entry.minor_version == 3
def _multi_pan_sky_connect_entry(firmware: str) -> MockConfigEntry:
"""Return a SkyConnect config entry with the given firmware type."""
return MockConfigEntry(
domain=DOMAIN,
unique_id="some_unique_id",
data={
"description": "SkyConnect v1.0",
"device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
"vid": "10C4",
"pid": "EA60",
"serial_number": "3c0ed67c628beb11b1cd64a0f320645d",
"manufacturer": "Nabu Casa",
"product": "SkyConnect v1.0",
"firmware": firmware,
"firmware_version": None,
},
title="Home Assistant SkyConnect",
version=1,
minor_version=4,
)
async def test_multi_pan_migration_issue_created_for_cpc(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test the multi-PAN migration repair issue is created when firmware is CPC."""
config_entry = _multi_pan_sky_connect_entry(ApplicationType.CPC.value)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homeassistant_sky_connect.os.path.exists",
return_value=True,
),
patch(
"homeassistant.components.homeassistant_sky_connect.multi_pan_addon_using_device",
return_value=False,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
)
assert issue is not None
assert issue.translation_key == ISSUE_MULTI_PAN_MIGRATION
assert issue.translation_placeholders == {
"hardware_name": "Home Assistant SkyConnect"
}
assert issue.data == {"entry_id": config_entry.entry_id}
assert issue.is_fixable
async def test_multi_pan_migration_issue_created_for_addon(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test the repair issue is created when the multi-PAN addon is running."""
config_entry = _multi_pan_sky_connect_entry(ApplicationType.SPINEL.value)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homeassistant_sky_connect.os.path.exists",
return_value=True,
),
patch(
"homeassistant.components.homeassistant_sky_connect.multi_pan_addon_using_device",
return_value=True,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
)
assert issue is not None
assert issue.is_fixable
async def test_multi_pan_migration_issue_deleted_for_ezsp(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test the multi-PAN migration repair issue is removed when not using multi-PAN."""
config_entry = _multi_pan_sky_connect_entry(ApplicationType.EZSP.value)
config_entry.add_to_hass(hass)
ir.async_create_issue(
hass,
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
is_fixable=True,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_MULTI_PAN_MIGRATION,
translation_placeholders={"hardware_name": "Home Assistant SkyConnect"},
data={"entry_id": config_entry.entry_id},
)
with (
patch(
"homeassistant.components.homeassistant_sky_connect.os.path.exists",
return_value=True,
),
patch(
"homeassistant.components.homeassistant_sky_connect.multi_pan_addon_using_device",
return_value=False,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert (
issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
)
is None
)
@@ -19,7 +19,6 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
CONF_DISABLE_MULTI_PAN,
get_flasher_addon_manager,
get_multiprotocol_addon_manager,
)
from homeassistant.components.homeassistant_hardware.util import (
@@ -549,15 +548,11 @@ async def test_options_flow_multipan_uninstall(hass: HomeAssistant) -> None:
version="1.0.0",
)
mock_flasher_manager = Mock(spec_set=get_flasher_addon_manager(hass))
mock_flasher_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={},
state=AddonState.NOT_RUNNING,
update_available=False,
version="1.0.0",
)
mock_fw_manifest = Mock()
mock_fw_manifest.filename = "yellow_zigbee_ncp_7.4.4.0.gbl"
mock_fw_client = AsyncMock()
mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest])
mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware"
with (
patch(
@@ -565,8 +560,12 @@ async def test_options_flow_multipan_uninstall(hass: HomeAssistant) -> None:
return_value=mock_multipan_manager,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_flasher_addon_manager",
return_value=mock_flasher_manager,
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient",
return_value=mock_fw_client,
),
patch(
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio",
@@ -595,11 +594,15 @@ async def test_options_flow_multipan_uninstall(hass: HomeAssistant) -> None:
result["flow_id"], user_input={CONF_DISABLE_MULTI_PAN: True}
)
# Finish the flow
# Uninstall multiprotocol addon
result = await hass.config_entries.options.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
# Flash zigbee firmware
result = await hass.config_entries.options.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
# Flashing complete
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -6,6 +6,9 @@ import pytest
from homeassistant.components import zha
from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
@@ -18,6 +21,7 @@ from homeassistant.components.usb import SerialDevice, async_scan_serial_ports
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, MockModule, mock_integration
@@ -321,6 +325,156 @@ async def test_setup_entry_addon_info_fails(
assert config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_multi_pan_migration_issue_created_for_cpc(
hass: HomeAssistant,
addon_store_info,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the multi-PAN migration repair issue is created when firmware is CPC."""
mock_integration(hass, MockModule("hassio"))
await async_setup_component(hass, HASSIO_DOMAIN, {})
config_entry = MockConfigEntry(
data={"firmware": ApplicationType.CPC},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homeassistant_yellow.get_os_info",
return_value={"board": "yellow"},
),
patch(
"homeassistant.components.onboarding.async_is_onboarded",
return_value=False,
),
patch(
"homeassistant.components.homeassistant_yellow.check_multi_pan_addon",
return_value=None,
),
patch(
"homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device",
return_value=False,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
)
assert issue is not None
assert issue.translation_key == ISSUE_MULTI_PAN_MIGRATION
assert issue.translation_placeholders == {"hardware_name": "Home Assistant Yellow"}
assert issue.data == {"entry_id": config_entry.entry_id}
assert issue.is_fixable
async def test_multi_pan_migration_issue_created_for_addon(
hass: HomeAssistant,
addon_store_info,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the repair issue is created when the multi-PAN addon is running."""
mock_integration(hass, MockModule("hassio"))
await async_setup_component(hass, HASSIO_DOMAIN, {})
config_entry = MockConfigEntry(
data={"firmware": ApplicationType.SPINEL},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homeassistant_yellow.get_os_info",
return_value={"board": "yellow"},
),
patch(
"homeassistant.components.onboarding.async_is_onboarded",
return_value=False,
),
patch(
"homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device",
return_value=True,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
)
assert issue is not None
assert issue.is_fixable
async def test_multi_pan_migration_issue_deleted_for_ezsp(
hass: HomeAssistant,
addon_store_info,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the multi-PAN migration repair issue is removed when not using multi-PAN."""
mock_integration(hass, MockModule("hassio"))
await async_setup_component(hass, HASSIO_DOMAIN, {})
config_entry = MockConfigEntry(
data={"firmware": ApplicationType.EZSP},
domain=DOMAIN,
options={},
title="Home Assistant Yellow",
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
# Pre-existing issue from a previous CPC run
ir.async_create_issue(
hass,
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
is_fixable=True,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_MULTI_PAN_MIGRATION,
translation_placeholders={"hardware_name": "Home Assistant Yellow"},
data={"entry_id": config_entry.entry_id},
)
with (
patch(
"homeassistant.components.homeassistant_yellow.get_os_info",
return_value={"board": "yellow"},
),
patch(
"homeassistant.components.onboarding.async_is_onboarded",
return_value=False,
),
patch(
"homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device",
return_value=False,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert (
issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}",
)
is None
)
@pytest.mark.parametrize(
("start_version", "data", "migrated_data"),
[
@@ -379,6 +533,10 @@ async def test_migrate_entry(
owners=[],
),
),
patch(
"homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device",
return_value=False,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()