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:
Dermot Duffy 2021-06-02 09:39:19 -07:00 committed by GitHub
parent 68714c2067
commit c057c9d9ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 508 additions and 20 deletions

View file

@ -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)

View 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

View file

@ -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)

View file

@ -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)

View file

@ -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),
)