mirror of
https://github.com/home-assistant/core.git
synced 2025-11-14 21:40:16 +00:00
372 lines
14 KiB
Python
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
|