mirror of
https://github.com/home-assistant/core.git
synced 2026-10-05 21:57:39 +01:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
631 lines
19 KiB
Python
631 lines
19 KiB
Python
"""Tests for the Elgato update platform."""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from elgato import (
|
|
ElgatoConnectionError,
|
|
ElgatoError,
|
|
ElgatoFirmwareError,
|
|
FirmwareImage,
|
|
FirmwareVersion,
|
|
)
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
|
|
from homeassistant.components.elgato import ELGATO_KEY
|
|
from homeassistant.components.elgato.const import (
|
|
DOMAIN,
|
|
FIRMWARE_SCAN_INTERVAL,
|
|
SCAN_INTERVAL,
|
|
)
|
|
from homeassistant.components.elgato.update import REBOOT_TIMEOUT
|
|
from homeassistant.components.homeassistant import (
|
|
DOMAIN as HA_DOMAIN,
|
|
SERVICE_UPDATE_ENTITY,
|
|
)
|
|
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
|
from homeassistant.components.update import (
|
|
ATTR_IN_PROGRESS,
|
|
ATTR_UPDATE_PERCENTAGE,
|
|
DOMAIN as UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_ENTITY_ID,
|
|
CONF_HOST,
|
|
CONF_MAC,
|
|
SERVICE_TURN_ON,
|
|
STATE_OFF,
|
|
STATE_ON,
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
|
from homeassistant.setup import async_setup_component
|
|
|
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
|
|
|
ENTITY_ID = "update.frenck_firmware"
|
|
|
|
pytestmark = [
|
|
pytest.mark.parametrize("device_fixtures", ["key-light"]),
|
|
pytest.mark.usefixtures("device_fixtures", "init_integration"),
|
|
]
|
|
|
|
|
|
async def test_update(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
entity_registry: er.EntityRegistry,
|
|
snapshot: SnapshotAssertion,
|
|
) -> None:
|
|
"""Test the Elgato firmware update entity."""
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state == snapshot
|
|
assert state.state == STATE_ON
|
|
|
|
assert (entry := entity_registry.async_get(ENTITY_ID))
|
|
assert entry == snapshot
|
|
|
|
assert entry.device_id
|
|
assert (device_entry := device_registry.async_get(entry.device_id))
|
|
assert device_entry == snapshot
|
|
|
|
|
|
async def test_up_to_date(
|
|
hass: HomeAssistant,
|
|
mock_firmware_catalog: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a device already running what Elgato ships.
|
|
|
|
The device fixture reports build 192, so the catalog is pulled back to
|
|
match it.
|
|
"""
|
|
mock_firmware_catalog.versions.return_value = {
|
|
53: FirmwareVersion(board_type=53, build_number=192, version="1.0.3")
|
|
}
|
|
freezer.tick(FIRMWARE_SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.state == STATE_OFF
|
|
|
|
|
|
async def test_elgato_ships_nothing_for_this_board(
|
|
hass: HomeAssistant,
|
|
mock_firmware_catalog: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a board Elgato publishes no firmware for.
|
|
|
|
Nothing to compare against is not an error, it just leaves the entity
|
|
with no opinion.
|
|
"""
|
|
mock_firmware_catalog.versions.return_value = {}
|
|
freezer.tick(FIRMWARE_SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.state == STATE_UNKNOWN
|
|
|
|
|
|
async def test_install(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
mock_firmware_catalog: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test installing the firmware Elgato ships."""
|
|
reported: list[int | None] = []
|
|
in_progress_while_downloading = None
|
|
|
|
async def download(board_type: int) -> FirmwareImage:
|
|
"""Stand in for fetching the image off Elgato's servers."""
|
|
nonlocal in_progress_while_downloading
|
|
in_progress_while_downloading = hass.states.get(ENTITY_ID).attributes[
|
|
ATTR_IN_PROGRESS
|
|
]
|
|
return FirmwareImage(
|
|
board_type=board_type,
|
|
build_number=222,
|
|
version="1.0.3",
|
|
data=b"\x00" * 8192,
|
|
)
|
|
|
|
async def install(
|
|
image: FirmwareImage,
|
|
*,
|
|
on_progress: Callable[[int, int], None] | None = None,
|
|
) -> None:
|
|
"""Stand in for a device taking a firmware image."""
|
|
assert on_progress is not None
|
|
for sent in (4096, 8192):
|
|
on_progress(sent, len(image.data))
|
|
reported.append(
|
|
hass.states.get(ENTITY_ID).attributes[ATTR_UPDATE_PERCENTAGE]
|
|
)
|
|
|
|
mock_firmware_catalog.download.side_effect = download
|
|
mock_elgato.update_firmware.side_effect = install
|
|
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
# Fetching the image is part of the install, so the entity says so
|
|
# before it starts rather than after.
|
|
assert in_progress_while_downloading is True
|
|
|
|
mock_firmware_catalog.download.assert_called_once_with(53)
|
|
mock_elgato.update_firmware.assert_called_once()
|
|
assert reported == [50, 100]
|
|
|
|
# Still installing: the device took the firmware and is restarting.
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.attributes[ATTR_IN_PROGRESS] is True
|
|
|
|
# It comes back on the build it was given.
|
|
mock_elgato.info.return_value.firmware_build_number = 222
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.attributes[ATTR_IN_PROGRESS] is False
|
|
assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None
|
|
assert state.state == STATE_OFF
|
|
|
|
|
|
async def test_install_that_never_comes_back(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a device that takes the firmware and never reports it.
|
|
|
|
Without a way out, the entity would sit there saying it is installing
|
|
for as long as Home Assistant runs.
|
|
"""
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.attributes[ATTR_IN_PROGRESS] is True
|
|
|
|
freezer.tick(timedelta(seconds=REBOOT_TIMEOUT + 1))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.attributes[ATTR_IN_PROGRESS] is False
|
|
|
|
|
|
async def test_install_on_a_device_that_stays_away(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a device that takes the firmware and never answers again.
|
|
|
|
It does not sit there claiming to install. An entity whose device is
|
|
gone is unavailable, and that is what it says.
|
|
"""
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_elgato.state.side_effect = ElgatoConnectionError
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
|
|
async def test_a_second_install_is_turned_away(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
) -> None:
|
|
"""Test two installs at once do not both reach the device."""
|
|
|
|
async def slow(image: FirmwareImage, **kwargs: Any) -> None:
|
|
"""Take long enough for the second call to arrive."""
|
|
await asyncio.sleep(0)
|
|
|
|
mock_elgato.update_firmware.side_effect = slow
|
|
|
|
results = await asyncio.gather(
|
|
*[
|
|
hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
for _ in range(2)
|
|
],
|
|
return_exceptions=True,
|
|
)
|
|
|
|
assert sum(isinstance(result, HomeAssistantError) for result in results) == 1
|
|
assert mock_elgato.update_firmware.call_count == 1
|
|
|
|
|
|
async def test_catalog_refresh_during_an_install(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
mock_firmware_catalog: MagicMock,
|
|
) -> None:
|
|
"""Test the catalog refreshing while an install is running.
|
|
|
|
What Elgato ships says nothing about whether this device is done, so a
|
|
refresh in the middle must not report the install as finished.
|
|
"""
|
|
|
|
async def install(image: FirmwareImage, **kwargs: Any) -> None:
|
|
"""Let Elgato publish something while the device is busy."""
|
|
await hass.data[ELGATO_KEY].async_refresh()
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.attributes[ATTR_IN_PROGRESS] is True
|
|
|
|
mock_elgato.update_firmware.side_effect = install
|
|
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert mock_elgato.update_firmware.call_count == 1
|
|
|
|
|
|
async def test_download_does_not_hold_the_device(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
mock_firmware_catalog: MagicMock,
|
|
) -> None:
|
|
"""Test fetching the image leaves the device free.
|
|
|
|
Downloading talks to Elgato. If it held the device lock, a slow or
|
|
unreachable Elgato would park every light command behind it for the
|
|
length of their timeout.
|
|
"""
|
|
coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data
|
|
locked_while_downloading = None
|
|
locked_while_uploading = None
|
|
|
|
async def download(board_type: int) -> FirmwareImage:
|
|
nonlocal locked_while_downloading
|
|
locked_while_downloading = coordinator.device_lock.locked()
|
|
return FirmwareImage(
|
|
board_type=board_type,
|
|
build_number=222,
|
|
version="1.0.3",
|
|
data=b"\x00" * 8192,
|
|
)
|
|
|
|
async def install(image: FirmwareImage, **kwargs: Any) -> None:
|
|
nonlocal locked_while_uploading
|
|
locked_while_uploading = coordinator.device_lock.locked()
|
|
|
|
mock_firmware_catalog.download.side_effect = download
|
|
mock_elgato.update_firmware.side_effect = install
|
|
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert locked_while_downloading is False
|
|
assert locked_while_uploading is True
|
|
|
|
|
|
async def test_device_page_follows_the_firmware(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_elgato: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test the device page shows the firmware after an install.
|
|
|
|
DeviceInfo is read when an entity is added and not again, so the version
|
|
someone reads right after installing would otherwise be the old one.
|
|
"""
|
|
device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, "CN11A1A00001"),
|
|
hass.config_entries.async_entries(DOMAIN)[0].entry_id,
|
|
)
|
|
assert device is not None
|
|
assert device.sw_version == "1.0.3 (192)"
|
|
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_elgato.info.return_value.firmware_build_number = 222
|
|
freezer.tick(SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, "CN11A1A00001"),
|
|
hass.config_entries.async_entries(DOMAIN)[0].entry_id,
|
|
)
|
|
assert device is not None
|
|
assert device.sw_version == "1.0.3 (222)"
|
|
|
|
|
|
async def test_install_error(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
) -> None:
|
|
"""Test a device refusing the firmware it was handed."""
|
|
mock_elgato.update_firmware.side_effect = ElgatoError
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError,
|
|
match="An unknown error occurred while communicating with the Elgato device",
|
|
):
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.attributes[ATTR_IN_PROGRESS] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"side_effect",
|
|
[ElgatoConnectionError, ElgatoError],
|
|
)
|
|
async def test_elgato_unreachable(
|
|
hass: HomeAssistant,
|
|
mock_firmware_catalog: MagicMock,
|
|
freezer: FrozenDateTimeFactory,
|
|
side_effect: type[Exception],
|
|
) -> None:
|
|
"""Test Elgato's servers being unreachable.
|
|
|
|
The light is on the local network and Elgato is not, so a bad day at
|
|
their end costs the latest version and nothing else. The light and its
|
|
other entities carry on.
|
|
"""
|
|
mock_firmware_catalog.versions.side_effect = side_effect
|
|
freezer.tick(FIRMWARE_SCAN_INTERVAL)
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
assert (light := hass.states.get("light.frenck"))
|
|
assert light.state != STATE_UNAVAILABLE
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("side_effect", "message", "still_reachable"),
|
|
[
|
|
(
|
|
ElgatoConnectionError,
|
|
"An error occurred while downloading the firmware from Elgato",
|
|
False,
|
|
),
|
|
(
|
|
ElgatoFirmwareError,
|
|
"An unknown error occurred while downloading the firmware from Elgato",
|
|
True,
|
|
),
|
|
],
|
|
)
|
|
async def test_download_failure_leaves_the_light_alone(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
mock_firmware_catalog: MagicMock,
|
|
side_effect: type[Exception],
|
|
message: str,
|
|
still_reachable: bool,
|
|
) -> None:
|
|
"""Test Elgato failing to hand over the image.
|
|
|
|
Only reaching Elgato says anything about this entity; an image that
|
|
arrives and fails to verify means Elgato answered, just badly.
|
|
"""
|
|
mock_firmware_catalog.download.side_effect = side_effect
|
|
|
|
with pytest.raises(HomeAssistantError, match=message):
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_elgato.update_firmware.assert_not_called()
|
|
|
|
assert (light := hass.states.get("light.frenck"))
|
|
assert light.state != STATE_UNAVAILABLE
|
|
|
|
assert (state := hass.states.get(ENTITY_ID))
|
|
assert (state.state != STATE_UNAVAILABLE) is still_reachable
|
|
|
|
|
|
async def test_install_rejected_by_the_device(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
) -> None:
|
|
"""Test a device turning the firmware away for a reason worth reading."""
|
|
mock_elgato.update_firmware.side_effect = ElgatoFirmwareError(
|
|
"Battery is at 11%, connect the device to power before updating its firmware"
|
|
)
|
|
|
|
with pytest.raises(HomeAssistantError, match="Battery is at 11%"):
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
|
|
async def test_install_keeps_the_device_to_itself(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
) -> None:
|
|
"""Test a poll cannot land in the middle of an install.
|
|
|
|
A device stops answering while it erases a flash slot, and enough traffic
|
|
during that window takes its HTTP server down and restarts the light.
|
|
"""
|
|
coordinator = hass.config_entries.async_entries(DOMAIN)[0].runtime_data
|
|
refresh: asyncio.Task[None] | None = None
|
|
polls_during_install = 0
|
|
|
|
async def install(image: FirmwareImage, **kwargs: Any) -> None:
|
|
"""Ask for a refresh while the device is busy taking firmware."""
|
|
nonlocal refresh, polls_during_install
|
|
before = mock_elgato.state.call_count
|
|
refresh = hass.async_create_task(coordinator.async_refresh())
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
polls_during_install = mock_elgato.state.call_count - before
|
|
|
|
mock_elgato.update_firmware.side_effect = install
|
|
polls_before = mock_elgato.state.call_count
|
|
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert refresh is not None
|
|
await refresh
|
|
|
|
assert polls_during_install == 0
|
|
# And it is not blocked forever; the poll lands once the install is done.
|
|
assert mock_elgato.state.call_count > polls_before
|
|
|
|
|
|
async def test_manual_update_check(
|
|
hass: HomeAssistant,
|
|
mock_firmware_catalog: MagicMock,
|
|
) -> None:
|
|
"""Test asking for an update check reaches Elgato.
|
|
|
|
The device coordinator knows what the light runs; only the catalog knows
|
|
what Elgato ships, and that is the half being asked about.
|
|
"""
|
|
await async_setup_component(hass, HA_DOMAIN, {})
|
|
checks_before = mock_firmware_catalog.versions.call_count
|
|
|
|
await hass.services.async_call(
|
|
HA_DOMAIN,
|
|
SERVICE_UPDATE_ENTITY,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert mock_firmware_catalog.versions.call_count > checks_before
|
|
|
|
|
|
async def test_install_keeps_the_device_from_everyone(
|
|
hass: HomeAssistant,
|
|
mock_elgato: MagicMock,
|
|
) -> None:
|
|
"""Test a light command cannot land in the middle of an install either.
|
|
|
|
Polling is not the only thing that talks to the device; every button,
|
|
switch and light action does too.
|
|
"""
|
|
turn_on: asyncio.Task[None] | None = None
|
|
commands_during_install = 0
|
|
|
|
async def install(image: FirmwareImage, **kwargs: Any) -> None:
|
|
"""Ask the light to turn on while the device is taking firmware."""
|
|
nonlocal turn_on, commands_during_install
|
|
before = mock_elgato.light.call_count
|
|
turn_on = hass.async_create_task(
|
|
hass.services.async_call(
|
|
LIGHT_DOMAIN,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: "light.frenck"},
|
|
blocking=True,
|
|
)
|
|
)
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
commands_during_install = mock_elgato.light.call_count - before
|
|
|
|
mock_elgato.update_firmware.side_effect = install
|
|
|
|
await hass.services.async_call(
|
|
UPDATE_DOMAIN,
|
|
SERVICE_INSTALL,
|
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert turn_on is not None
|
|
await turn_on
|
|
|
|
assert commands_during_install == 0
|
|
assert mock_elgato.light.call_count == 1
|
|
|
|
|
|
async def test_one_catalog_for_every_device(
|
|
hass: HomeAssistant,
|
|
mock_firmware_catalog: MagicMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a second device does not fetch the catalog all over again.
|
|
|
|
Elgato publishes one catalog covering every model, so it is read once
|
|
and shared, not once per config entry.
|
|
"""
|
|
calls_for_one_device = mock_firmware_catalog.versions.call_count
|
|
|
|
second = MockConfigEntry(
|
|
title="CN11A1A00002",
|
|
domain=DOMAIN,
|
|
data={CONF_HOST: "127.0.0.2", CONF_MAC: "AA:BB:CC:DD:EE:00"},
|
|
unique_id="CN11A1A00002",
|
|
)
|
|
second.add_to_hass(hass)
|
|
await hass.config_entries.async_setup(second.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert mock_firmware_catalog.versions.call_count == calls_for_one_device
|