core/tests/components/zha/test_repairs.py

448 lines
14 KiB
Python

"""Test ZHA repairs."""
from collections.abc import Callable
from http import HTTPStatus
import logging
from unittest.mock import Mock, call, patch
import pytest
from universal_silabs_flasher.const import ApplicationType
from universal_silabs_flasher.flasher import Flasher
from zigpy.application import ControllerApplication
import zigpy.backups
from zigpy.exceptions import NetworkSettingsInconsistent
from homeassistant.components.homeassistant_sky_connect.const import ( # pylint: disable=hass-component-root-import
DOMAIN as SKYCONNECT_DOMAIN,
)
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.components.zha.const import DOMAIN
from homeassistant.components.zha.repairs.network_settings_inconsistent import (
ISSUE_INCONSISTENT_NETWORK_SETTINGS,
)
from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
DISABLE_MULTIPAN_URL,
ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED,
HardwareType,
_detect_radio_hardware,
probe_silabs_firmware_type,
warn_on_wrong_silabs_firmware,
)
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
from tests.typing import ClientSessionGenerator
SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0"
CONNECT_ZBT1_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0"
def set_flasher_app_type(app_type: ApplicationType) -> Callable[[Flasher], None]:
"""Set the app type on the flasher."""
def replacement(self: Flasher) -> None:
self.app_type = app_type
return replacement
def test_detect_radio_hardware(hass: HomeAssistant) -> None:
"""Test logic to detect radio hardware."""
skyconnect_config_entry = MockConfigEntry(
data={
"device": SKYCONNECT_DEVICE,
"vid": "10C4",
"pid": "EA60",
"serial_number": "3c0ed67c628beb11b1cd64a0f320645d",
"manufacturer": "Nabu Casa",
"product": "SkyConnect v1.0",
"firmware": "ezsp",
},
version=2,
domain=SKYCONNECT_DOMAIN,
options={},
title="Home Assistant SkyConnect",
)
skyconnect_config_entry.add_to_hass(hass)
connect_zbt1_config_entry = MockConfigEntry(
data={
"device": CONNECT_ZBT1_DEVICE,
"vid": "10C4",
"pid": "EA60",
"serial_number": "3c0ed67c628beb11b1cd64a0f320645d",
"manufacturer": "Nabu Casa",
"product": "Home Assistant Connect ZBT-1",
"firmware": "ezsp",
},
version=2,
domain=SKYCONNECT_DOMAIN,
options={},
title="Home Assistant Connect ZBT-1",
)
connect_zbt1_config_entry.add_to_hass(hass)
assert _detect_radio_hardware(hass, CONNECT_ZBT1_DEVICE) == HardwareType.SKYCONNECT
assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.SKYCONNECT
assert (
_detect_radio_hardware(hass, SKYCONNECT_DEVICE + "_foo") == HardwareType.OTHER
)
assert _detect_radio_hardware(hass, "/dev/ttyAMA1") == HardwareType.OTHER
with patch(
"homeassistant.components.homeassistant_yellow.hardware.get_os_info",
return_value={"board": "yellow"},
):
assert _detect_radio_hardware(hass, "/dev/ttyAMA1") == HardwareType.YELLOW
assert _detect_radio_hardware(hass, "/dev/ttyAMA2") == HardwareType.OTHER
assert (
_detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.SKYCONNECT
)
def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None:
"""Test radio hardware detection failure."""
with (
patch(
"homeassistant.components.homeassistant_yellow.hardware.async_info",
side_effect=HomeAssistantError(),
),
patch(
"homeassistant.components.homeassistant_sky_connect.hardware.async_info",
side_effect=HomeAssistantError(),
),
):
assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER
@pytest.mark.parametrize(
("detected_hardware", "expected_learn_more_url"),
[
(HardwareType.SKYCONNECT, DISABLE_MULTIPAN_URL[HardwareType.SKYCONNECT]),
(HardwareType.YELLOW, DISABLE_MULTIPAN_URL[HardwareType.YELLOW]),
(HardwareType.OTHER, None),
],
)
async def test_multipan_firmware_repair(
hass: HomeAssistant,
detected_hardware: HardwareType,
expected_learn_more_url: str,
config_entry: MockConfigEntry,
mock_zigpy_connect: ControllerApplication,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test creating a repair when multi-PAN firmware is installed and probed."""
config_entry.add_to_hass(hass)
# ZHA fails to set up
with (
patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type",
side_effect=set_flasher_app_type(ApplicationType.CPC),
autospec=True,
),
patch(
"homeassistant.components.zha.Gateway.async_initialize",
side_effect=RuntimeError(),
),
patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware._detect_radio_hardware",
return_value=detected_hardware,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
await hass.config_entries.async_unload(config_entry.entry_id)
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED,
)
# The issue is created when we fail to probe
assert issue is not None
assert issue.translation_placeholders["firmware_type"] == "CPC"
assert issue.learn_more_url == expected_learn_more_url
# If ZHA manages to start up normally after this, the issue will be deleted
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=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED,
)
assert issue is None
async def test_multipan_firmware_no_repair_on_probe_failure(
hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry
) -> None:
"""Test that a repair is not created when multi-PAN firmware cannot be probed."""
config_entry.add_to_hass(hass)
# ZHA fails to set up
with (
patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type",
side_effect=set_flasher_app_type(None),
autospec=True,
),
patch(
"homeassistant.components.zha.Gateway.async_initialize",
side_effect=RuntimeError(),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
await hass.config_entries.async_unload(config_entry.entry_id)
# No repair is created
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED,
)
assert issue is None
async def test_multipan_firmware_retry_on_probe_ezsp(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_zigpy_connect: ControllerApplication,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that ZHA is reloaded when EZSP firmware is probed."""
config_entry.add_to_hass(hass)
# ZHA fails to set up
with (
patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type",
side_effect=set_flasher_app_type(ApplicationType.EZSP),
autospec=True,
),
patch(
"homeassistant.components.zha.Gateway.async_initialize",
side_effect=RuntimeError(),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# The config entry state is `SETUP_RETRY`, not `SETUP_ERROR`!
assert config_entry.state is ConfigEntryState.SETUP_RETRY
await hass.config_entries.async_unload(config_entry.entry_id)
# No repair is created
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED,
)
assert issue is None
async def test_no_warn_on_socket(hass: HomeAssistant) -> None:
"""Test that no warning is issued when the device is a socket."""
with patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type",
autospec=True,
) as mock_probe:
await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678")
mock_probe.assert_not_called()
async def test_probe_failure_exception_handling(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that probe failures are handled gracefully."""
logger = logging.getLogger(
"homeassistant.components.zha.repairs.wrong_silabs_firmware"
)
orig_level = logger.level
with (
caplog.at_level(logging.DEBUG),
patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type",
side_effect=RuntimeError(),
) as mock_probe_app_type,
):
logger.setLevel(logging.DEBUG)
await probe_silabs_firmware_type("/dev/ttyZigbee")
logger.setLevel(orig_level)
mock_probe_app_type.assert_awaited()
assert "Failed to probe application type" in caplog.text
async def test_inconsistent_settings_keep_new(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
mock_zigpy_connect: ControllerApplication,
network_backup: zigpy.backups.NetworkBackup,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test inconsistent ZHA network settings: keep new settings."""
assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}})
config_entry.add_to_hass(hass)
new_state = network_backup.replace(
network_info=network_backup.network_info.replace(pan_id=0xBBBB)
)
old_state = network_backup
with patch(
"homeassistant.components.zha.Gateway.async_initialize",
side_effect=NetworkSettingsInconsistent(
message="Network settings are inconsistent",
new_state=new_state,
old_state=old_state,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
await hass.config_entries.async_unload(config_entry.entry_id)
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS,
)
# The issue is created
assert issue is not None
client = await hass_client()
resp = await client.post(
"/api/repairs/issues/fix",
json={"handler": DOMAIN, "issue_id": issue.issue_id},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data["description_placeholders"]["diff"] == "- PAN ID: `0x2DB4` → `0xBBBB`"
mock_zigpy_connect.backups.add_backup = Mock()
resp = await client.post(
f"/api/repairs/issues/fix/{flow_id}",
json={"next_step_id": "use_new_settings"},
)
await hass.async_block_till_done()
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data["type"] == "create_entry"
await hass.config_entries.async_unload(config_entry.entry_id)
assert (
issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS,
)
is None
)
assert mock_zigpy_connect.backups.add_backup.mock_calls == [call(new_state)]
async def test_inconsistent_settings_restore_old(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
mock_zigpy_connect: ControllerApplication,
network_backup: zigpy.backups.NetworkBackup,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test inconsistent ZHA network settings: restore last backup."""
assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}})
config_entry.add_to_hass(hass)
new_state = network_backup.replace(
network_info=network_backup.network_info.replace(pan_id=0xBBBB)
)
old_state = network_backup
with patch(
"homeassistant.components.zha.Gateway.async_initialize",
side_effect=NetworkSettingsInconsistent(
message="Network settings are inconsistent",
new_state=new_state,
old_state=old_state,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
await hass.config_entries.async_unload(config_entry.entry_id)
issue = issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS,
)
# The issue is created
assert issue is not None
client = await hass_client()
resp = await client.post(
"/api/repairs/issues/fix",
json={"handler": DOMAIN, "issue_id": issue.issue_id},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data["description_placeholders"]["diff"] == "- PAN ID: `0x2DB4` → `0xBBBB`"
resp = await client.post(
f"/api/repairs/issues/fix/{flow_id}",
json={"next_step_id": "restore_old_settings"},
)
await hass.async_block_till_done()
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data["type"] == "create_entry"
await hass.config_entries.async_unload(config_entry.entry_id)
assert (
issue_registry.async_get_issue(
domain=DOMAIN,
issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS,
)
is None
)
assert mock_zigpy_connect.backups.restore_backup.mock_calls == [call(old_state)]