251 lines
7.8 KiB
Python
251 lines
7.8 KiB
Python
"""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
|
|
from typing import Any
|
|
|
|
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 HomeAssistant, callback
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
from homeassistant.helpers.dispatcher import (
|
|
async_dispatcher_connect,
|
|
async_dispatcher_send,
|
|
)
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
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,
|
|
SIGNAL_ENTITY_REMOVE,
|
|
TYPE_HYPERION_CAMERA,
|
|
)
|
|
|
|
IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64,"
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""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)
|
|
|
|
|
|
# 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."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
|
|
def __init__(
|
|
self,
|
|
server_id: str,
|
|
instance_num: int,
|
|
instance_name: str,
|
|
hyperion_client: client.HyperionClient,
|
|
) -> None:
|
|
"""Initialize the switch."""
|
|
super().__init__()
|
|
|
|
self._attr_unique_id = get_hyperion_unique_id(
|
|
server_id, instance_num, TYPE_HYPERION_CAMERA
|
|
)
|
|
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
|
|
}
|
|
self._attr_device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, self._device_id)},
|
|
manufacturer=HYPERION_MANUFACTURER_NAME,
|
|
model=HYPERION_MODEL_NAME,
|
|
name=instance_name,
|
|
configuration_url=hyperion_client.remote_url,
|
|
)
|
|
|
|
@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.removeprefix(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._attr_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._attr_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, width: int | None = None, height: int | None = None
|
|
) -> 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."""
|
|
self.async_on_remove(
|
|
async_dispatcher_connect(
|
|
self.hass,
|
|
SIGNAL_ENTITY_REMOVE.format(self._attr_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)
|
|
|
|
|
|
CAMERA_TYPES = {
|
|
TYPE_HYPERION_CAMERA: HyperionCamera,
|
|
}
|