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

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

View 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,
}

View File

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

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