Home Assistant Unofficial Reference 2024.12.1
camera.py
Go to the documentation of this file.
1 """Switch platform for Hyperion."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import base64
7 import binascii
8 from collections.abc import AsyncGenerator
9 from contextlib import asynccontextmanager
10 import functools
11 from typing import Any
12 
13 from aiohttp import web
14 from hyperion import client
15 from hyperion.const import (
16  KEY_IMAGE,
17  KEY_IMAGE_STREAM,
18  KEY_LEDCOLORS,
19  KEY_RESULT,
20  KEY_UPDATE,
21 )
22 
24  DEFAULT_CONTENT_TYPE,
25  Camera,
26  async_get_still_stream,
27 )
28 from homeassistant.config_entries import ConfigEntry
29 from homeassistant.core import HomeAssistant, callback
30 from homeassistant.helpers.device_registry import DeviceInfo
32  async_dispatcher_connect,
33  async_dispatcher_send,
34 )
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 
37 from . import (
38  get_hyperion_device_id,
39  get_hyperion_unique_id,
40  listen_for_instance_updates,
41 )
42 from .const import (
43  CONF_INSTANCE_CLIENTS,
44  DOMAIN,
45  HYPERION_MANUFACTURER_NAME,
46  HYPERION_MODEL_NAME,
47  SIGNAL_ENTITY_REMOVE,
48  TYPE_HYPERION_CAMERA,
49 )
50 
51 IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64,"
52 
53 
55  hass: HomeAssistant,
56  config_entry: ConfigEntry,
57  async_add_entities: AddEntitiesCallback,
58 ) -> None:
59  """Set up a Hyperion platform from config entry."""
60  entry_data = hass.data[DOMAIN][config_entry.entry_id]
61  server_id = config_entry.unique_id
62 
63  def camera_unique_id(instance_num: int) -> str:
64  """Return the camera unique_id."""
65  assert server_id
66  return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_CAMERA)
67 
68  @callback
69  def instance_add(instance_num: int, instance_name: str) -> None:
70  """Add entities for a new Hyperion instance."""
71  assert server_id
73  [
75  server_id,
76  instance_num,
77  instance_name,
78  entry_data[CONF_INSTANCE_CLIENTS][instance_num],
79  )
80  ]
81  )
82 
83  @callback
84  def instance_remove(instance_num: int) -> None:
85  """Remove entities for an old Hyperion instance."""
86  assert server_id
88  hass,
89  SIGNAL_ENTITY_REMOVE.format(
90  camera_unique_id(instance_num),
91  ),
92  )
93 
94  listen_for_instance_updates(hass, config_entry, instance_add, instance_remove)
95 
96 
97 # A note on Hyperion streaming semantics:
98 #
99 # Different Hyperion priorities behave different with regards to streaming. Colors will
100 # not stream (as there is nothing to stream). External grabbers (e.g. USB Capture) will
101 # stream what is being captured. Some effects (based on GIFs) will stream, others will
102 # not. In cases when streaming is not supported from a selected priority, there is no
103 # notification beyond the failure of new frames to arrive.
104 
105 
107  """ComponentBinarySwitch switch class."""
108 
109  _attr_has_entity_name = True
110  _attr_name = None
111 
112  def __init__(
113  self,
114  server_id: str,
115  instance_num: int,
116  instance_name: str,
117  hyperion_client: client.HyperionClient,
118  ) -> None:
119  """Initialize the switch."""
120  super().__init__()
121 
123  server_id, instance_num, TYPE_HYPERION_CAMERA
124  )
125  self._device_id_device_id = get_hyperion_device_id(server_id, instance_num)
126  self._instance_name_instance_name = instance_name
127  self._client_client = hyperion_client
128 
129  self._image_cond_image_cond = asyncio.Condition()
130  self._image_image: bytes | None = None
131 
132  # The number of open streams, when zero the stream is stopped.
133  self._image_stream_clients_image_stream_clients = 0
134 
135  self._client_callbacks_client_callbacks = {
136  f"{KEY_LEDCOLORS}-{KEY_IMAGE_STREAM}-{KEY_UPDATE}": self._update_imagestream_update_imagestream
137  }
138  self._attr_device_info_attr_device_info = DeviceInfo(
139  identifiers={(DOMAIN, self._device_id_device_id)},
140  manufacturer=HYPERION_MANUFACTURER_NAME,
141  model=HYPERION_MODEL_NAME,
142  name=instance_name,
143  configuration_url=hyperion_client.remote_url,
144  )
145 
146  @property
147  def is_on(self) -> bool:
148  """Return true if the camera is on."""
149  return self.availableavailableavailableavailable
150 
151  @property
152  def available(self) -> bool:
153  """Return server availability."""
154  return bool(self._client_client.has_loaded_state)
155 
156  async def _update_imagestream(self, img: dict[str, Any] | None = None) -> None:
157  """Update Hyperion components."""
158  if not img:
159  return
160  img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
161  if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
162  return
163  async with self._image_cond_image_cond:
164  try:
165  self._image_image = base64.b64decode(
166  img_data.removeprefix(IMAGE_STREAM_JPG_SENTINEL)
167  )
168  except binascii.Error:
169  return
170  self._image_cond_image_cond.notify_all()
171 
172  async def _async_wait_for_camera_image(self) -> bytes | None:
173  """Return a single camera image in a stream."""
174  async with self._image_cond_image_cond:
175  await self._image_cond_image_cond.wait()
176  return self._image_image if self.availableavailableavailableavailable else None
177 
178  async def _start_image_streaming_for_client(self) -> bool:
179  """Start streaming for a client."""
180  if (
181  not self._image_stream_clients_image_stream_clients
182  and not await self._client_client.async_send_image_stream_start()
183  ):
184  return False
185 
186  self._image_stream_clients_image_stream_clients += 1
187  self._attr_is_streaming_attr_is_streaming = True
188  self.async_write_ha_stateasync_write_ha_stateasync_write_ha_state()
189  return True
190 
191  async def _stop_image_streaming_for_client(self) -> None:
192  """Stop streaming for a client."""
193  self._image_stream_clients_image_stream_clients -= 1
194 
195  if not self._image_stream_clients_image_stream_clients:
196  await self._client_client.async_send_image_stream_stop()
197  self._attr_is_streaming_attr_is_streaming = False
198  self.async_write_ha_stateasync_write_ha_stateasync_write_ha_state()
199 
200  @asynccontextmanager
201  async def _image_streaming(self) -> AsyncGenerator:
202  """Async context manager to start/stop image streaming."""
203  try:
204  yield await self._start_image_streaming_for_client_start_image_streaming_for_client()
205  finally:
206  await self._stop_image_streaming_for_client_stop_image_streaming_for_client()
207 
209  self, width: int | None = None, height: int | None = None
210  ) -> bytes | None:
211  """Return single camera image bytes."""
212  async with self._image_streaming_image_streaming() as is_streaming:
213  if is_streaming:
214  return await self._async_wait_for_camera_image_async_wait_for_camera_image()
215  return None
216 
218  self, request: web.Request
219  ) -> web.StreamResponse | None:
220  """Serve an HTTP MJPEG stream from the camera."""
221  async with self._image_streaming_image_streaming() as is_streaming:
222  if is_streaming:
223  return await async_get_still_stream(
224  request,
225  self._async_wait_for_camera_image_async_wait_for_camera_image,
226  DEFAULT_CONTENT_TYPE,
227  0.0,
228  )
229  return None
230 
231  async def async_added_to_hass(self) -> None:
232  """Register callbacks when entity added to hass."""
233  self.async_on_removeasync_on_remove(
235  self.hasshass,
236  SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id_attr_unique_id),
237  functools.partial(self.async_removeasync_remove, force_remove=True),
238  )
239  )
240 
241  self._client_client.add_callbacks(self._client_callbacks_client_callbacks)
242 
243  async def async_will_remove_from_hass(self) -> None:
244  """Cleanup prior to hass removal."""
245  self._client_client.remove_callbacks(self._client_callbacks_client_callbacks)
246 
247 
248 CAMERA_TYPES = {
249  TYPE_HYPERION_CAMERA: HyperionCamera,
250 }
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:210
web.StreamResponse|None handle_async_mjpeg_stream(self, web.Request request)
Definition: camera.py:219
None _update_imagestream(self, dict[str, Any]|None img=None)
Definition: camera.py:156
None __init__(self, str server_id, int instance_num, str instance_name, client.HyperionClient hyperion_client)
Definition: camera.py:118
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_remove(self, *bool force_remove=False)
Definition: entity.py:1387
web.StreamResponse async_get_still_stream(web.Request request, Callable[[], Awaitable[bytes|None]] image_cb, str content_type, float interval)
Definition: __init__.py:296
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: camera.py:58
str get_hyperion_device_id(str server_id, int instance)
Definition: __init__.py:71
str get_hyperion_unique_id(str server_id, int instance, str name)
Definition: __init__.py:66
None listen_for_instance_updates(HomeAssistant hass, ConfigEntry config_entry, Callable add_func, Callable remove_func)
Definition: __init__.py:113
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193