Files
core/tests/components/homeassistant_hardware/test_helpers.py

372 lines
14 KiB
Python

"""Test hardware helpers."""
from collections.abc import Callable
import logging
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
import pytest
from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT
from homeassistant.components.homeassistant_hardware.helpers import (
async_firmware_update_context,
async_is_firmware_update_in_progress,
async_notify_firmware_info,
async_register_firmware_info_callback,
async_register_firmware_info_provider,
async_register_firmware_update_in_progress,
async_unregister_firmware_update_in_progress,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.usb import USBDevice
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
FIRMWARE_INFO_EZSP = FirmwareInfo(
device="/dev/serial/by-id/device1",
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="zha",
owners=[AsyncMock(is_running=AsyncMock(return_value=True))],
)
FIRMWARE_INFO_SPINEL = FirmwareInfo(
device="/dev/serial/by-id/device2",
firmware_type=ApplicationType.SPINEL,
firmware_version=None,
source="otbr",
owners=[AsyncMock(is_running=AsyncMock(return_value=True))],
)
async def test_dispatcher_registration(hass: HomeAssistant) -> None:
"""Test HardwareInfoDispatcher registration."""
await async_setup_component(hass, "homeassistant_hardware", {})
# Mock provider 1 with a synchronous method to pull firmware info
provider1_config_entry = MockConfigEntry(
domain="zha",
unique_id="some_unique_id1",
data={},
)
provider1_config_entry.add_to_hass(hass)
provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED)
provider1_firmware = MagicMock(spec=["get_firmware_info"])
provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP)
async_register_firmware_info_provider(hass, "zha", provider1_firmware)
# Mock provider 2 with an asynchronous method to pull firmware info
provider2_config_entry = MockConfigEntry(
domain="otbr",
unique_id="some_unique_id2",
data={},
)
provider2_config_entry.add_to_hass(hass)
provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED)
provider2_firmware = MagicMock(spec=["async_get_firmware_info"])
provider2_firmware.async_get_firmware_info = AsyncMock(
return_value=FIRMWARE_INFO_SPINEL
)
async_register_firmware_info_provider(hass, "otbr", provider2_firmware)
# Double registration won't work
with pytest.raises(ValueError, match="Domain zha is already registered"):
async_register_firmware_info_provider(hass, "zha", provider1_firmware)
# We can iterate over the results
info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()]
assert info == [
FIRMWARE_INFO_EZSP,
FIRMWARE_INFO_SPINEL,
]
callback1 = Mock()
cancel1 = async_register_firmware_info_callback(
hass, "/dev/serial/by-id/device1", callback1
)
callback2 = Mock()
cancel2 = async_register_firmware_info_callback(
hass, "/dev/serial/by-id/device2", callback2
)
# And receive notification callbacks
await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL)
await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
cancel1()
await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL)
cancel2()
assert callback1.mock_calls == [
call(FIRMWARE_INFO_EZSP),
call(FIRMWARE_INFO_EZSP),
]
assert callback2.mock_calls == [
call(FIRMWARE_INFO_SPINEL),
call(FIRMWARE_INFO_SPINEL),
]
async def test_dispatcher_iter_error_handling(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test HardwareInfoDispatcher ignoring errors from firmware info providers."""
await async_setup_component(hass, "homeassistant_hardware", {})
provider1_config_entry = MockConfigEntry(
domain="zha",
unique_id="some_unique_id1",
data={},
)
provider1_config_entry.add_to_hass(hass)
provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED)
provider1_firmware = MagicMock(spec=["get_firmware_info"])
provider1_firmware.get_firmware_info = MagicMock(side_effect=Exception("Boom!"))
async_register_firmware_info_provider(hass, "zha", provider1_firmware)
provider2_config_entry = MockConfigEntry(
domain="otbr",
unique_id="some_unique_id2",
data={},
)
provider2_config_entry.add_to_hass(hass)
provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED)
provider2_firmware = MagicMock(spec=["async_get_firmware_info"])
provider2_firmware.async_get_firmware_info = AsyncMock(
return_value=FIRMWARE_INFO_SPINEL
)
async_register_firmware_info_provider(hass, "otbr", provider2_firmware)
with caplog.at_level(logging.ERROR):
info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()]
assert info == [FIRMWARE_INFO_SPINEL]
assert "Error while getting firmware info from" in caplog.text
async def test_dispatcher_callback_error_handling(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test HardwareInfoDispatcher ignoring errors from firmware info callbacks."""
await async_setup_component(hass, "homeassistant_hardware", {})
provider1_config_entry = MockConfigEntry(
domain="zha",
unique_id="some_unique_id1",
data={},
)
provider1_config_entry.add_to_hass(hass)
provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED)
provider1_firmware = MagicMock(spec=["get_firmware_info"])
provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP)
async_register_firmware_info_provider(hass, "zha", provider1_firmware)
callback1 = Mock(side_effect=Exception("Some error"))
async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback1)
callback2 = Mock()
async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback2)
with caplog.at_level(logging.ERROR):
await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
assert "Error while notifying firmware info listener" in caplog.text
assert callback1.mock_calls == [call(FIRMWARE_INFO_EZSP)]
assert callback2.mock_calls == [call(FIRMWARE_INFO_EZSP)]
async def test_firmware_update_tracking(hass: HomeAssistant) -> None:
"""Test firmware update tracking API."""
await async_setup_component(hass, "homeassistant_hardware", {})
device_path = "/dev/ttyUSB0"
assert not async_is_firmware_update_in_progress(hass, device_path)
# Register an update in progress
async_register_firmware_update_in_progress(hass, device_path, "zha")
assert async_is_firmware_update_in_progress(hass, device_path)
with pytest.raises(ValueError, match="Firmware update already in progress"):
async_register_firmware_update_in_progress(hass, device_path, "skyconnect")
assert async_is_firmware_update_in_progress(hass, device_path)
# Unregister the update with correct domain
async_unregister_firmware_update_in_progress(hass, device_path, "zha")
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test unregistering with wrong domain should raise an error
async_register_firmware_update_in_progress(hass, device_path, "zha")
with pytest.raises(ValueError, match="is owned by zha, not skyconnect"):
async_unregister_firmware_update_in_progress(hass, device_path, "skyconnect")
# Still registered to zha
assert async_is_firmware_update_in_progress(hass, device_path)
async_unregister_firmware_update_in_progress(hass, device_path, "zha")
assert not async_is_firmware_update_in_progress(hass, device_path)
async def test_firmware_update_context_manager(hass: HomeAssistant) -> None:
"""Test firmware update progress context manager."""
await async_setup_component(hass, "homeassistant_hardware", {})
device_path = "/dev/ttyUSB0"
# Initially no updates in progress
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test successful completion
async with async_firmware_update_context(hass, device_path, "zha"):
assert async_is_firmware_update_in_progress(hass, device_path)
# Should be cleaned up after context
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test exception handling
with pytest.raises(ValueError, match="test error"): # noqa: PT012
async with async_firmware_update_context(hass, device_path, "zha"):
assert async_is_firmware_update_in_progress(hass, device_path)
raise ValueError("test error")
# Should still be cleaned up after exception
assert not async_is_firmware_update_in_progress(hass, device_path)
# Test concurrent context manager attempts should fail
async with async_firmware_update_context(hass, device_path, "zha"):
assert async_is_firmware_update_in_progress(hass, device_path)
# Second context manager should fail to register
with pytest.raises(ValueError, match="Firmware update already in progress"):
async with async_firmware_update_context(hass, device_path, "skyconnect"):
pytest.fail("We should not enter this context manager")
# Should be cleaned up after first context
assert not async_is_firmware_update_in_progress(hass, device_path)
async def test_dispatcher_callback_self_unregister(hass: HomeAssistant) -> None:
"""Test callbacks can unregister themselves during notification."""
await async_setup_component(hass, "homeassistant_hardware", {})
called_callbacks = []
unregister_funcs = {}
def create_self_unregistering_callback(name: str) -> Callable[[FirmwareInfo], None]:
def callback(firmware_info: FirmwareInfo) -> None:
called_callbacks.append(name)
unregister_funcs[name]()
return callback
callback1 = create_self_unregistering_callback("callback1")
callback2 = create_self_unregistering_callback("callback2")
callback3 = create_self_unregistering_callback("callback3")
# Register all three callbacks and store their unregister functions
unregister_funcs["callback1"] = async_register_firmware_info_callback(
hass, "/dev/serial/by-id/device1", callback1
)
unregister_funcs["callback2"] = async_register_firmware_info_callback(
hass, "/dev/serial/by-id/device1", callback2
)
unregister_funcs["callback3"] = async_register_firmware_info_callback(
hass, "/dev/serial/by-id/device1", callback3
)
# All callbacks should be called and unregister themselves
await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
assert set(called_callbacks) == {"callback1", "callback2", "callback3"}
# No callbacks should be called since they all unregistered
called_callbacks.clear()
await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP)
assert not called_callbacks
async def test_firmware_callback_no_usb_device(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test firmware notification when usb_device_from_path returns None."""
await async_setup_component(hass, "homeassistant_hardware", {})
await async_setup_component(hass, "usb", {})
with (
patch(
"homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path",
return_value=None,
),
caplog.at_level(logging.DEBUG),
):
await async_notify_firmware_info(
hass,
"zha",
FirmwareInfo(
device="/dev/ttyUSB99",
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0",
owners=[],
source="zha",
),
)
await hass.async_block_till_done()
# This isn't a codepath that's expected but we won't fail in this case, just log
assert "Cannot find USB for path /dev/ttyUSB99" in caplog.text
async def test_firmware_callback_no_hardware_domain(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test firmware notification when no hardware domain is found for device."""
await async_setup_component(hass, "homeassistant_hardware", {})
await async_setup_component(hass, "usb", {})
# Create a USB device that doesn't match any hardware integration
usb_device = USBDevice(
device="/dev/ttyUSB0",
vid="9999",
pid="9999",
serial_number="TEST123",
manufacturer="Test Manufacturer",
description="Test Device",
)
with (
patch(
"homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path",
return_value=usb_device,
),
caplog.at_level(logging.DEBUG),
):
await async_notify_firmware_info(
hass,
"zha",
FirmwareInfo(
device="/dev/ttyUSB0",
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0",
owners=[],
source="zha",
),
)
await hass.async_block_till_done()
assert "No hardware integration found for device" in caplog.text