From c057c9d9aba7140914ce30bcd54075d6b27fb58d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 2 Jun 2021 09:39:19 -0700 Subject: [PATCH] Add Hyperion camera feed (#46516) * Initial Hyperion camera. * Improve test coverage. * Minor state fixes. * Fix type annotation. * May rebase and updates (mostly typing). * Updates to use new camera typing improvements. * Use new support for returning None from async_get_mjpeg_stream . * Codereview feedback. * Lint: Use AsyncGenerator from collections.abc . * Update homeassistant/components/hyperion/camera.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/hyperion/__init__.py | 3 +- homeassistant/components/hyperion/camera.py | 263 ++++++++++++++++++ homeassistant/components/hyperion/const.py | 2 + tests/components/hyperion/__init__.py | 13 +- tests/components/hyperion/test_camera.py | 211 ++++++++++++++ tests/components/hyperion/test_config_flow.py | 26 +- tests/components/hyperion/test_light.py | 8 +- tests/components/hyperion/test_switch.py | 2 +- 8 files changed, 508 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/hyperion/camera.py create mode 100644 tests/components/hyperion/test_camera.py diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 5baa86d6926..891f48e8738 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -9,6 +9,7 @@ from typing import Any, Callable, cast from awesomeversion import AwesomeVersion from hyperion import client, const as hyperion_const +from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -34,7 +35,7 @@ from .const import ( SIGNAL_INSTANCE_REMOVE, ) -PLATFORMS = [LIGHT_DOMAIN, SWITCH_DOMAIN] +PLATFORMS = [LIGHT_DOMAIN, SWITCH_DOMAIN, CAMERA_DOMAIN] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py new file mode 100644 index 00000000000..733a91c1c58 --- /dev/null +++ b/homeassistant/components/hyperion/camera.py @@ -0,0 +1,263 @@ +"""Switch platform for Hyperion.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +import functools +import logging +from typing import Any, Callable + +from aiohttp import web +from hyperion import client +from hyperion.const import ( + KEY_IMAGE, + KEY_IMAGE_STREAM, + KEY_LEDCOLORS, + KEY_RESULT, + KEY_UPDATE, +) + +from homeassistant.components.camera import ( + DEFAULT_CONTENT_TYPE, + Camera, + async_get_still_stream, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + get_hyperion_device_id, + get_hyperion_unique_id, + listen_for_instance_updates, +) +from .const import ( + CONF_INSTANCE_CLIENTS, + DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, + NAME_SUFFIX_HYPERION_CAMERA, + SIGNAL_ENTITY_REMOVE, + TYPE_HYPERION_CAMERA, +) + +_LOGGER = logging.getLogger(__name__) + +IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64," + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +) -> bool: + """Set up a Hyperion platform from config entry.""" + entry_data = hass.data[DOMAIN][config_entry.entry_id] + server_id = config_entry.unique_id + + def camera_unique_id(instance_num: int) -> str: + """Return the camera unique_id.""" + assert server_id + return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_CAMERA) + + @callback + def instance_add(instance_num: int, instance_name: str) -> None: + """Add entities for a new Hyperion instance.""" + assert server_id + async_add_entities( + [ + HyperionCamera( + server_id, + instance_num, + instance_name, + entry_data[CONF_INSTANCE_CLIENTS][instance_num], + ) + ] + ) + + @callback + def instance_remove(instance_num: int) -> None: + """Remove entities for an old Hyperion instance.""" + assert server_id + async_dispatcher_send( + hass, + SIGNAL_ENTITY_REMOVE.format( + camera_unique_id(instance_num), + ), + ) + + listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + return True + + +# A note on Hyperion streaming semantics: +# +# Different Hyperion priorities behave different with regards to streaming. Colors will +# not stream (as there is nothing to stream). External grabbers (e.g. USB Capture) will +# stream what is being captured. Some effects (based on GIFs) will stream, others will +# not. In cases when streaming is not supported from a selected priority, there is no +# notification beyond the failure of new frames to arrive. + + +class HyperionCamera(Camera): + """ComponentBinarySwitch switch class.""" + + def __init__( + self, + server_id: str, + instance_num: int, + instance_name: str, + hyperion_client: client.HyperionClient, + ) -> None: + """Initialize the switch.""" + super().__init__() + + self._unique_id = get_hyperion_unique_id( + server_id, instance_num, TYPE_HYPERION_CAMERA + ) + self._name = f"{instance_name} {NAME_SUFFIX_HYPERION_CAMERA}".strip() + self._device_id = get_hyperion_device_id(server_id, instance_num) + self._instance_name = instance_name + self._client = hyperion_client + + self._image_cond = asyncio.Condition() + self._image: bytes | None = None + + # The number of open streams, when zero the stream is stopped. + self._image_stream_clients = 0 + + self._client_callbacks = { + f"{KEY_LEDCOLORS}-{KEY_IMAGE_STREAM}-{KEY_UPDATE}": self._update_imagestream + } + + @property + def unique_id(self) -> str: + """Return a unique id for this instance.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the switch.""" + return self._name + + @property + def is_on(self) -> bool: + """Return true if the camera is on.""" + return self.available + + @property + def available(self) -> bool: + """Return server availability.""" + return bool(self._client.has_loaded_state) + + async def _update_imagestream(self, img: dict[str, Any] | None = None) -> None: + """Update Hyperion components.""" + if not img: + return + img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE) + if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL): + return + async with self._image_cond: + try: + self._image = base64.b64decode( + img_data[len(IMAGE_STREAM_JPG_SENTINEL) :] + ) + except binascii.Error: + return + self._image_cond.notify_all() + + async def _async_wait_for_camera_image(self) -> bytes | None: + """Return a single camera image in a stream.""" + async with self._image_cond: + await self._image_cond.wait() + return self._image if self.available else None + + async def _start_image_streaming_for_client(self) -> bool: + """Start streaming for a client.""" + if ( + not self._image_stream_clients + and not await self._client.async_send_image_stream_start() + ): + return False + + self._image_stream_clients += 1 + self.is_streaming = True + self.async_write_ha_state() + return True + + async def _stop_image_streaming_for_client(self) -> None: + """Stop streaming for a client.""" + self._image_stream_clients -= 1 + + if not self._image_stream_clients: + await self._client.async_send_image_stream_stop() + self.is_streaming = False + self.async_write_ha_state() + + @asynccontextmanager + async def _image_streaming(self) -> AsyncGenerator: + """Async context manager to start/stop image streaming.""" + try: + yield await self._start_image_streaming_for_client() + finally: + await self._stop_image_streaming_for_client() + + async def async_camera_image(self) -> bytes | None: + """Return single camera image bytes.""" + async with self._image_streaming() as is_streaming: + if is_streaming: + return await self._async_wait_for_camera_image() + return None + + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse | None: + """Serve an HTTP MJPEG stream from the camera.""" + async with self._image_streaming() as is_streaming: + if is_streaming: + return await async_get_still_stream( + request, + self._async_wait_for_camera_image, + DEFAULT_CONTENT_TYPE, + 0.0, + ) + return None + + async def async_added_to_hass(self) -> None: + """Register callbacks when entity added to hass.""" + assert self.hass + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_ENTITY_REMOVE.format(self._unique_id), + functools.partial(self.async_remove, force_remove=True), + ) + ) + + self._client.add_callbacks(self._client_callbacks) + + async def async_will_remove_from_hass(self) -> None: + """Cleanup prior to hass removal.""" + self._client.remove_callbacks(self._client_callbacks) + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._instance_name, + "manufacturer": HYPERION_MANUFACTURER_NAME, + "model": HYPERION_MODEL_NAME, + } + + +CAMERA_TYPES = { + TYPE_HYPERION_CAMERA: HyperionCamera, +} diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 9deeba9d019..271618fb962 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -24,11 +24,13 @@ HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" NAME_SUFFIX_HYPERION_LIGHT = "" NAME_SUFFIX_HYPERION_PRIORITY_LIGHT = "Priority" NAME_SUFFIX_HYPERION_COMPONENT_SWITCH = "Component" +NAME_SUFFIX_HYPERION_CAMERA = "" SIGNAL_INSTANCE_ADD = f"{DOMAIN}_instance_add_signal." "{}" SIGNAL_INSTANCE_REMOVE = f"{DOMAIN}_instance_remove_signal." "{}" SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal." "{}" +TYPE_HYPERION_CAMERA = "hyperion_camera" TYPE_HYPERION_LIGHT = "hyperion_light" TYPE_HYPERION_PRIORITY_LIGHT = "hyperion_priority_light" TYPE_HYPERION_COMPONENT_SWITCH_BASE = "hyperion_component_switch" diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index ac77a5a0407..0789a344ec6 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -125,7 +125,7 @@ def add_test_config_entry( options: dict[str, Any] | None = None, ) -> ConfigEntry: """Add a test config entry.""" - config_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call] + config_entry: MockConfigEntry = MockConfigEntry( entry_id=TEST_CONFIG_ENTRY_ID, domain=DOMAIN, data=data @@ -137,7 +137,7 @@ def add_test_config_entry( unique_id=TEST_SYSINFO_ID, options=options or TEST_CONFIG_ENTRY_OPTIONS, ) - config_entry.add_to_hass(hass) # type: ignore[no-untyped-call] + config_entry.add_to_hass(hass) return config_entry @@ -187,3 +187,12 @@ def register_test_entity( suggested_object_id=entity_id, disabled_by=None, ) + + +async def async_call_registered_callback( + client: AsyncMock, key: str, *args: Any, **kwargs: Any +) -> None: + """Call Hyperion entity callbacks that were registered with the client.""" + for call in client.add_callbacks.call_args_list: + if key in call[0][0]: + await call[0][0][key](*args, **kwargs) diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py new file mode 100644 index 00000000000..daed1ec2cc9 --- /dev/null +++ b/tests/components/hyperion/test_camera.py @@ -0,0 +1,211 @@ +"""Tests for the Hyperion integration.""" +from __future__ import annotations + +import asyncio +import base64 +from collections.abc import Awaitable +import logging +from typing import Callable +from unittest.mock import AsyncMock, Mock, patch + +from aiohttp import web +import pytest + +from homeassistant.components.camera import ( + DEFAULT_CONTENT_TYPE, + DOMAIN as CAMERA_DOMAIN, + async_get_image, + async_get_mjpeg_stream, +) +from homeassistant.components.hyperion import get_hyperion_device_id +from homeassistant.components.hyperion.const import ( + DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, + TYPE_HYPERION_CAMERA, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ( + TEST_CONFIG_ENTRY_ID, + TEST_INSTANCE, + TEST_INSTANCE_1, + TEST_SYSINFO_ID, + async_call_registered_callback, + create_mock_client, + register_test_entity, + setup_test_config_entry, +) + +_LOGGER = logging.getLogger(__name__) +TEST_CAMERA_ENTITY_ID = "camera.test_instance_1" +TEST_IMAGE_DATA = "TEST DATA" +TEST_IMAGE_UPDATE = { + "command": "ledcolors-imagestream-update", + "result": { + "image": "data:image/jpg;base64," + + base64.b64encode(TEST_IMAGE_DATA.encode()).decode("ascii"), + }, + "success": True, +} + + +async def test_camera_setup(hass: HomeAssistant) -> None: + """Test turning the light on.""" + client = create_mock_client() + + await setup_test_config_entry(hass, hyperion_client=client) + + # Verify switch is on (as per TEST_COMPONENTS above). + entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID) + assert entity_state + assert entity_state.state == "idle" + + +async def test_camera_image(hass: HomeAssistant) -> None: + """Test retrieving a single camera image.""" + client = create_mock_client() + client.async_send_image_stream_start = AsyncMock(return_value=True) + client.async_send_image_stream_stop = AsyncMock(return_value=True) + + await setup_test_config_entry(hass, hyperion_client=client) + + get_image_coro = async_get_image(hass, TEST_CAMERA_ENTITY_ID) + image_stream_update_coro = async_call_registered_callback( + client, "ledcolors-imagestream-update", TEST_IMAGE_UPDATE + ) + result = await asyncio.gather(get_image_coro, image_stream_update_coro) + + assert client.async_send_image_stream_start.called + assert client.async_send_image_stream_stop.called + assert result[0].content == TEST_IMAGE_DATA.encode() + + +async def test_camera_invalid_image(hass: HomeAssistant) -> None: + """Test retrieving a single invalid camera image.""" + client = create_mock_client() + client.async_send_image_stream_start = AsyncMock(return_value=True) + client.async_send_image_stream_stop = AsyncMock(return_value=True) + + await setup_test_config_entry(hass, hyperion_client=client) + + get_image_coro = async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=0) + image_stream_update_coro = async_call_registered_callback( + client, "ledcolors-imagestream-update", None + ) + with pytest.raises(HomeAssistantError): + await asyncio.gather(get_image_coro, image_stream_update_coro) + + get_image_coro = async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=0) + image_stream_update_coro = async_call_registered_callback( + client, "ledcolors-imagestream-update", {"garbage": 1} + ) + with pytest.raises(HomeAssistantError): + await asyncio.gather(get_image_coro, image_stream_update_coro) + + get_image_coro = async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=0) + image_stream_update_coro = async_call_registered_callback( + client, + "ledcolors-imagestream-update", + {"result": {"image": "data:image/jpg;base64,FOO"}}, + ) + with pytest.raises(HomeAssistantError): + await asyncio.gather(get_image_coro, image_stream_update_coro) + + +async def test_camera_image_failed_start_stream_call(hass: HomeAssistant) -> None: + """Test retrieving a single camera image with failed start stream call.""" + client = create_mock_client() + client.async_send_image_stream_start = AsyncMock(return_value=False) + + await setup_test_config_entry(hass, hyperion_client=client) + + with pytest.raises(HomeAssistantError): + await async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=0) + + assert client.async_send_image_stream_start.called + assert not client.async_send_image_stream_stop.called + + +async def test_camera_stream(hass: HomeAssistant) -> None: + """Test retrieving a camera stream.""" + client = create_mock_client() + client.async_send_image_stream_start = AsyncMock(return_value=True) + client.async_send_image_stream_stop = AsyncMock(return_value=True) + + request = Mock() + + async def fake_get_still_stream( + in_request: web.Request, + callback: Callable[[], Awaitable[bytes | None]], + content_type: str, + interval: float, + ) -> bytes | None: + assert request == in_request + assert content_type == DEFAULT_CONTENT_TYPE + assert interval == 0.0 + return await callback() + + await setup_test_config_entry(hass, hyperion_client=client) + + with patch( + "homeassistant.components.hyperion.camera.async_get_still_stream", + ) as fake: + fake.side_effect = fake_get_still_stream + + get_stream_coro = async_get_mjpeg_stream(hass, request, TEST_CAMERA_ENTITY_ID) + image_stream_update_coro = async_call_registered_callback( + client, "ledcolors-imagestream-update", TEST_IMAGE_UPDATE + ) + result = await asyncio.gather(get_stream_coro, image_stream_update_coro) + + assert client.async_send_image_stream_start.called + assert client.async_send_image_stream_stop.called + assert result[0] == TEST_IMAGE_DATA.encode() + + +async def test_camera_stream_failed_start_stream_call(hass: HomeAssistant) -> None: + """Test retrieving a camera stream with failed start stream call.""" + client = create_mock_client() + client.async_send_image_stream_start = AsyncMock(return_value=False) + + await setup_test_config_entry(hass, hyperion_client=client) + + request = Mock() + assert not await async_get_mjpeg_stream(hass, request, TEST_CAMERA_ENTITY_ID) + + assert client.async_send_image_stream_start.called + assert not client.async_send_image_stream_stop.called + + +async def test_device_info(hass: HomeAssistant) -> None: + """Verify device information includes expected details.""" + client = create_mock_client() + + register_test_entity( + hass, + CAMERA_DOMAIN, + TYPE_HYPERION_CAMERA, + TEST_CAMERA_ENTITY_ID, + ) + await setup_test_config_entry(hass, hyperion_client=client) + + device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device({(DOMAIN, device_id)}) + assert device + assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.identifiers == {(DOMAIN, device_id)} + assert device.manufacturer == HYPERION_MANUFACTURER_NAME + assert device.model == HYPERION_MODEL_NAME + assert device.name == TEST_INSTANCE_1["friendly_name"] + + entity_registry = await er.async_get_registry(hass) + entities_from_device = [ + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, device.id) + ] + assert TEST_CAMERA_ENTITY_ID in entities_from_device diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index d8b12e3c72b..7260d589e71 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from typing import Any, Awaitable +from collections.abc import Awaitable +from typing import Any from unittest.mock import AsyncMock, Mock, patch from hyperion import const @@ -26,6 +27,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult from . import ( TEST_AUTH_REQUIRED_RESP, @@ -100,7 +102,7 @@ TEST_SSDP_SERVICE_INFO = { async def _create_mock_entry(hass: HomeAssistant) -> MockConfigEntry: """Add a test Hyperion entity to hass.""" - entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call] + entry: MockConfigEntry = MockConfigEntry( entry_id=TEST_CONFIG_ENTRY_ID, domain=DOMAIN, unique_id=TEST_SYSINFO_ID, @@ -111,7 +113,7 @@ async def _create_mock_entry(hass: HomeAssistant) -> MockConfigEntry: "instance": TEST_INSTANCE, }, ) - entry.add_to_hass(hass) # type: ignore[no-untyped-call] + entry.add_to_hass(hass) # Setup client = create_mock_client() @@ -138,7 +140,7 @@ async def _init_flow( async def _configure_flow( - hass: HomeAssistant, result: dict, user_input: dict[str, Any] | None = None + hass: HomeAssistant, result: FlowResult, user_input: dict[str, Any] | None = None ) -> Any: """Provide input to a flow.""" user_input = user_input or {} @@ -419,6 +421,11 @@ async def test_auth_create_token_approval_declined_task_canceled( class CanceledAwaitableMock(AsyncMock): """A canceled awaitable mock.""" + def __init__(self): + super().__init__() + self.done = Mock(return_value=False) + self.cancel = Mock() + def __await__(self) -> None: raise asyncio.CancelledError @@ -435,20 +442,15 @@ async def test_auth_create_token_approval_declined_task_canceled( ), patch( "homeassistant.components.hyperion.config_flow.client.generate_random_auth_id", return_value=TEST_AUTH_ID, - ), patch.object( - hass, "async_create_task", side_effect=create_task ): result = await _configure_flow( hass, result, user_input={CONF_CREATE_TOKEN: True} ) assert result["step_id"] == "create_token" - result = await _configure_flow(hass, result) - assert result["step_id"] == "create_token_external" - - # Leave the task running, to ensure it is canceled. - mock_task.done = Mock(return_value=False) - mock_task.cancel = Mock() + with patch.object(hass, "async_create_task", side_effect=create_task): + result = await _configure_flow(hass, result) + assert result["step_id"] == "create_token_external" result = await _configure_flow(hass, result) diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 0c6b2cf41df..866b1b1b1a8 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -862,7 +862,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert client.async_client_disconnect.call_count == 2 -async def test_version_log_warning(caplog, hass: HomeAssistant) -> None: # type: ignore[no-untyped-def] +async def test_version_log_warning(caplog, hass: HomeAssistant) -> None: """Test warning on old version.""" client = create_mock_client() client.async_sysinfo_version = AsyncMock(return_value="2.0.0-alpha.7") @@ -871,7 +871,7 @@ async def test_version_log_warning(caplog, hass: HomeAssistant) -> None: # type assert "Please consider upgrading" in caplog.text -async def test_version_no_log_warning(caplog, hass: HomeAssistant) -> None: # type: ignore[no-untyped-def] +async def test_version_no_log_warning(caplog, hass: HomeAssistant) -> None: """Test no warning on acceptable version.""" client = create_mock_client() client.async_sysinfo_version = AsyncMock(return_value="2.0.0-alpha.9") @@ -1359,7 +1359,7 @@ async def test_lights_can_be_enabled(hass: HomeAssistant) -> None: assert not updated_entry.disabled await hass.async_block_till_done() - async_fire_time_changed( # type: ignore[no-untyped-call] + async_fire_time_changed( hass, dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) @@ -1369,7 +1369,7 @@ async def test_lights_can_be_enabled(hass: HomeAssistant) -> None: assert entity_state -async def test_deprecated_effect_names(caplog, hass: HomeAssistant) -> None: # type: ignore[no-untyped-def] +async def test_deprecated_effect_names(caplog, hass: HomeAssistant) -> None: """Test deprecated effects function and issue a warning.""" client = create_mock_client() client.async_send_clear = AsyncMock(return_value=True) diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index 2367ad96133..e88db516eff 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -213,7 +213,7 @@ async def test_switches_can_be_enabled(hass: HomeAssistant) -> None: assert not updated_entry.disabled await hass.async_block_till_done() - async_fire_time_changed( # type: ignore[no-untyped-call] + async_fire_time_changed( hass, dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), )