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
8 changed files with 508 additions and 20 deletions
|
@ -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…
Add table
Add a link
Reference in a new issue