mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
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 <paulus@home-assistant.io>
This commit is contained in:
parent
68714c2067
commit
c057c9d9ab
@ -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__)
|
||||
|
||||
|
263
homeassistant/components/hyperion/camera.py
Normal file
263
homeassistant/components/hyperion/camera.py
Normal file
@ -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,
|
||||
}
|
@ -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"
|
||||
|
@ -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)
|
||||
|
211
tests/components/hyperion/test_camera.py
Normal file
211
tests/components/hyperion/test_camera.py
Normal file
@ -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
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user