mirror of https://github.com/home-assistant/core
448 lines
14 KiB
Python
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)]
|