Home Assistant Unofficial Reference 2024.12.1
type_cameras.py
Go to the documentation of this file.
1 """Class to hold all camera accessories."""
2 
3 import asyncio
4 from datetime import timedelta
5 import logging
6 from typing import Any
7 
8 from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg
9 from pyhap.camera import (
10  VIDEO_CODEC_PARAM_LEVEL_TYPES,
11  VIDEO_CODEC_PARAM_PROFILE_ID_TYPES,
12  Camera as PyhapCamera,
13 )
14 from pyhap.const import CATEGORY_CAMERA
15 from pyhap.util import callback as pyhap_callback
16 
17 from homeassistant.components import camera
18 from homeassistant.components.ffmpeg import get_ffmpeg_manager
19 from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
20 from homeassistant.core import (
21  Event,
22  EventStateChangedData,
23  HassJobType,
24  HomeAssistant,
25  State,
26  callback,
27 )
28 from homeassistant.helpers.event import (
29  async_track_state_change_event,
30  async_track_time_interval,
31 )
32 from homeassistant.util.async_ import create_eager_task
33 
34 from .accessories import TYPES, HomeAccessory, HomeDriver
35 from .const import (
36  CHAR_MOTION_DETECTED,
37  CHAR_MUTE,
38  CHAR_PROGRAMMABLE_SWITCH_EVENT,
39  CONF_AUDIO_CODEC,
40  CONF_AUDIO_MAP,
41  CONF_AUDIO_PACKET_SIZE,
42  CONF_LINKED_DOORBELL_SENSOR,
43  CONF_LINKED_MOTION_SENSOR,
44  CONF_MAX_FPS,
45  CONF_MAX_HEIGHT,
46  CONF_MAX_WIDTH,
47  CONF_STREAM_ADDRESS,
48  CONF_STREAM_COUNT,
49  CONF_STREAM_SOURCE,
50  CONF_SUPPORT_AUDIO,
51  CONF_VIDEO_CODEC,
52  CONF_VIDEO_MAP,
53  CONF_VIDEO_PACKET_SIZE,
54  CONF_VIDEO_PROFILE_NAMES,
55  DEFAULT_AUDIO_CODEC,
56  DEFAULT_AUDIO_MAP,
57  DEFAULT_AUDIO_PACKET_SIZE,
58  DEFAULT_MAX_FPS,
59  DEFAULT_MAX_HEIGHT,
60  DEFAULT_MAX_WIDTH,
61  DEFAULT_STREAM_COUNT,
62  DEFAULT_SUPPORT_AUDIO,
63  DEFAULT_VIDEO_CODEC,
64  DEFAULT_VIDEO_MAP,
65  DEFAULT_VIDEO_PACKET_SIZE,
66  DEFAULT_VIDEO_PROFILE_NAMES,
67  SERV_DOORBELL,
68  SERV_MOTION_SENSOR,
69  SERV_SPEAKER,
70  SERV_STATELESS_PROGRAMMABLE_SWITCH,
71 )
72 from .util import pid_is_alive, state_changed_event_is_same_state
73 
74 _LOGGER = logging.getLogger(__name__)
75 
76 DOORBELL_SINGLE_PRESS = 0
77 DOORBELL_DOUBLE_PRESS = 1
78 DOORBELL_LONG_PRESS = 2
79 
80 VIDEO_OUTPUT = (
81  "-map {v_map} -an "
82  "-c:v {v_codec} "
83  "{v_profile}"
84  "-tune zerolatency -pix_fmt yuv420p "
85  "-r {fps} "
86  "-b:v {v_max_bitrate}k -bufsize {v_bufsize}k -maxrate {v_max_bitrate}k "
87  "-payload_type 99 "
88  "-ssrc {v_ssrc} -f rtp "
89  "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} "
90  "srtp://{address}:{v_port}?rtcpport={v_port}&"
91  "localrtpport={v_port}&pkt_size={v_pkt_size}"
92 )
93 
94 AUDIO_OUTPUT = (
95  "-map {a_map} -vn "
96  "-c:a {a_encoder} "
97  "{a_application}"
98  "-ac 1 -ar {a_sample_rate}k "
99  "-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
100  "-payload_type 110 "
101  "-ssrc {a_ssrc} -f rtp "
102  "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} "
103  "srtp://{address}:{a_port}?rtcpport={a_port}&"
104  "localrtpport={a_port}&pkt_size={a_pkt_size}"
105 )
106 
107 SLOW_RESOLUTIONS = [
108  (320, 180, 15),
109  (320, 240, 15),
110 ]
111 
112 RESOLUTIONS = [
113  (320, 180),
114  (320, 240),
115  (480, 270),
116  (480, 360),
117  (640, 360),
118  (640, 480),
119  (1024, 576),
120  (1024, 768),
121  (1280, 720),
122  (1280, 960),
123  (1920, 1080),
124  (1600, 1200),
125 ]
126 
127 FFMPEG_WATCH_INTERVAL = timedelta(seconds=5)
128 FFMPEG_LOGGER = "ffmpeg_logger"
129 FFMPEG_WATCHER = "ffmpeg_watcher"
130 FFMPEG_PID = "ffmpeg_pid"
131 SESSION_ID = "session_id"
132 
133 CONFIG_DEFAULTS = {
134  CONF_SUPPORT_AUDIO: DEFAULT_SUPPORT_AUDIO,
135  CONF_MAX_WIDTH: DEFAULT_MAX_WIDTH,
136  CONF_MAX_HEIGHT: DEFAULT_MAX_HEIGHT,
137  CONF_MAX_FPS: DEFAULT_MAX_FPS,
138  CONF_AUDIO_CODEC: DEFAULT_AUDIO_CODEC,
139  CONF_AUDIO_MAP: DEFAULT_AUDIO_MAP,
140  CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP,
141  CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC,
142  CONF_VIDEO_PROFILE_NAMES: DEFAULT_VIDEO_PROFILE_NAMES,
143  CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE,
144  CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE,
145  CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT,
146 }
147 
148 
149 @TYPES.register("Camera")
150 # False-positive on pylint, not a CameraEntity
151 # pylint: disable-next=hass-enforce-class-module
152 class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
153  """Generate a Camera accessory."""
154 
155  def __init__(
156  self,
157  hass: HomeAssistant,
158  driver: HomeDriver,
159  name: str,
160  entity_id: str,
161  aid: int,
162  config: dict[str, Any],
163  ) -> None:
164  """Initialize a Camera accessory object."""
165  self._ffmpeg_ffmpeg = get_ffmpeg_manager(hass)
166  for config_key, conf in CONFIG_DEFAULTS.items():
167  if config_key not in config:
168  config[config_key] = conf
169 
170  max_fps = config[CONF_MAX_FPS]
171  max_width = config[CONF_MAX_WIDTH]
172  max_height = config[CONF_MAX_HEIGHT]
173  resolutions = [
174  (w, h, fps)
175  for w, h, fps in SLOW_RESOLUTIONS
176  if w <= max_width and h <= max_height and fps < max_fps
177  ] + [
178  (w, h, max_fps)
179  for w, h in RESOLUTIONS
180  if w <= max_width and h <= max_height
181  ]
182 
183  video_options = {
184  "codec": {
185  "profiles": [
186  VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["BASELINE"],
187  VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["MAIN"],
188  VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["HIGH"],
189  ],
190  "levels": [
191  VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_1"],
192  VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_2"],
193  VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE4_0"],
194  ],
195  },
196  "resolutions": resolutions,
197  }
198  audio_options = {
199  "codecs": [
200  {"type": "OPUS", "samplerate": 24},
201  {"type": "OPUS", "samplerate": 16},
202  ]
203  }
204 
205  stream_address = config.get(CONF_STREAM_ADDRESS, driver.state.address)
206 
207  options = {
208  "video": video_options,
209  "audio": audio_options,
210  "address": stream_address,
211  "srtp": True,
212  "stream_count": config[CONF_STREAM_COUNT],
213  }
214 
215  super().__init__(
216  hass,
217  driver,
218  name,
219  entity_id,
220  aid,
221  config,
222  category=CATEGORY_CAMERA,
223  options=options,
224  )
225 
226  self._char_motion_detected_char_motion_detected = None
227  self.linked_motion_sensor: str | None = self.configconfig.get(
228  CONF_LINKED_MOTION_SENSOR
229  )
230  self.motion_is_eventmotion_is_event = False
231  if linked_motion_sensor := self.linked_motion_sensor:
232  self.motion_is_eventmotion_is_event = linked_motion_sensor.startswith("event.")
233  if state := self.hasshass.states.get(linked_motion_sensor):
234  serv_motion = self.add_preload_service(SERV_MOTION_SENSOR)
235  self._char_motion_detected_char_motion_detected = serv_motion.configure_char(
236  CHAR_MOTION_DETECTED, value=False
237  )
238  self._async_update_motion_state_async_update_motion_state(None, state)
239 
240  self._char_doorbell_detected_char_doorbell_detected = None
241  self._char_doorbell_detected_switch_char_doorbell_detected_switch = None
242  linked_doorbell_sensor: str | None = self.configconfig.get(
243  CONF_LINKED_DOORBELL_SENSOR
244  )
245  self.linked_doorbell_sensorlinked_doorbell_sensor = linked_doorbell_sensor
246  self.doorbell_is_eventdoorbell_is_event = False
247  if not linked_doorbell_sensor:
248  return
249  self.doorbell_is_eventdoorbell_is_event = linked_doorbell_sensor.startswith("event.")
250  if not (state := self.hasshass.states.get(linked_doorbell_sensor)):
251  return
252  serv_doorbell = self.add_preload_service(SERV_DOORBELL)
253  self.set_primary_service(serv_doorbell)
254  self._char_doorbell_detected_char_doorbell_detected = serv_doorbell.configure_char(
255  CHAR_PROGRAMMABLE_SWITCH_EVENT,
256  value=0,
257  )
258  serv_stateless_switch = self.add_preload_service(
259  SERV_STATELESS_PROGRAMMABLE_SWITCH
260  )
261  self._char_doorbell_detected_switch_char_doorbell_detected_switch = serv_stateless_switch.configure_char(
262  CHAR_PROGRAMMABLE_SWITCH_EVENT,
263  value=0,
264  valid_values={"SinglePress": DOORBELL_SINGLE_PRESS},
265  )
266  serv_speaker = self.add_preload_service(SERV_SPEAKER)
267  serv_speaker.configure_char(CHAR_MUTE, value=0)
268  self._async_update_doorbell_state_async_update_doorbell_state(None, state)
269 
270  @pyhap_callback # type: ignore[misc]
271  @callback
272  def run(self) -> None:
273  """Handle accessory driver started event.
274 
275  Run inside the Home Assistant event loop.
276  """
277  if self._char_motion_detected_char_motion_detected:
278  assert self.linked_motion_sensor
279  self._subscriptions.append(
281  self.hasshass,
282  self.linked_motion_sensor,
283  self._async_update_motion_state_event_async_update_motion_state_event,
284  job_type=HassJobType.Callback,
285  )
286  )
287 
288  if self._char_doorbell_detected_char_doorbell_detected:
289  assert self.linked_doorbell_sensorlinked_doorbell_sensor
290  self._subscriptions.append(
292  self.hasshass,
293  self.linked_doorbell_sensorlinked_doorbell_sensor,
294  self._async_update_doorbell_state_event_async_update_doorbell_state_event,
295  job_type=HassJobType.Callback,
296  )
297  )
298 
299  super().run()
300 
301  @callback
303  self, event: Event[EventStateChangedData]
304  ) -> None:
305  """Handle state change event listener callback."""
306  if not state_changed_event_is_same_state(event) and (
307  new_state := event.data["new_state"]
308  ):
309  self._async_update_motion_state_async_update_motion_state(event.data["old_state"], new_state)
310 
311  @callback
313  self, old_state: State | None, new_state: State
314  ) -> None:
315  """Handle link motion sensor state change to update HomeKit value."""
316  state = new_state.state
317  char = self._char_motion_detected_char_motion_detected
318  assert char is not None
319  if self.motion_is_eventmotion_is_event:
320  if (
321  old_state is None
322  or old_state.state == STATE_UNAVAILABLE
323  or state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
324  ):
325  return
326  _LOGGER.debug(
327  "%s: Set linked motion %s sensor to True/False",
328  self.entity_identity_id,
329  self.linked_motion_sensor,
330  )
331  char.set_value(True)
332  char.set_value(False)
333  return
334 
335  detected = state == STATE_ON
336  if char.value == detected:
337  return
338 
339  char.set_value(detected)
340  _LOGGER.debug(
341  "%s: Set linked motion %s sensor to %d",
342  self.entity_identity_id,
343  self.linked_motion_sensor,
344  detected,
345  )
346 
347  @callback
349  self, event: Event[EventStateChangedData]
350  ) -> None:
351  """Handle state change event listener callback."""
352  if not state_changed_event_is_same_state(event) and (
353  new_state := event.data["new_state"]
354  ):
355  self._async_update_doorbell_state_async_update_doorbell_state(event.data["old_state"], new_state)
356 
357  @callback
359  self, old_state: State | None, new_state: State
360  ) -> None:
361  """Handle link doorbell sensor state change to update HomeKit value."""
362  assert self._char_doorbell_detected_char_doorbell_detected
363  assert self._char_doorbell_detected_switch_char_doorbell_detected_switch
364  state = new_state.state
365  if state == STATE_ON or (
366  self.doorbell_is_eventdoorbell_is_event
367  and old_state is not None
368  and old_state.state != STATE_UNAVAILABLE
369  and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
370  ):
371  self._char_doorbell_detected_char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS)
372  self._char_doorbell_detected_switch_char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS)
373  _LOGGER.debug(
374  "%s: Set linked doorbell %s sensor to %d",
375  self.entity_identity_id,
376  self.linked_doorbell_sensorlinked_doorbell_sensor,
377  DOORBELL_SINGLE_PRESS,
378  )
379 
380  @callback
381  def async_update_state(self, new_state: State | None) -> None:
382  """Handle state change to update HomeKit value."""
383 
384  async def _async_get_stream_source(self) -> str | None:
385  """Find the camera stream source url."""
386  stream_source: str | None = self.configconfig.get(CONF_STREAM_SOURCE)
387  if stream_source:
388  return stream_source
389  try:
390  stream_source = await camera.async_get_stream_source(
391  self.hasshass, self.entity_identity_id
392  )
393  except Exception:
394  _LOGGER.exception(
395  "Failed to get stream source - this could be a transient error or your"
396  " camera might not be compatible with HomeKit yet"
397  )
398  return stream_source
399 
400  async def start_stream(
401  self, session_info: dict[str, Any], stream_config: dict[str, Any]
402  ) -> bool:
403  """Start a new stream with the given configuration."""
404  _LOGGER.debug(
405  "[%s] Starting stream with the following parameters: %s",
406  session_info["id"],
407  stream_config,
408  )
409  if not (input_source := await self._async_get_stream_source_async_get_stream_source()):
410  _LOGGER.error("Camera has no stream source")
411  return False
412  if "-i " not in input_source:
413  input_source = "-i " + input_source
414  video_profile = ""
415  if self.configconfig[CONF_VIDEO_CODEC] != "copy":
416  video_profile = (
417  "-profile:v "
418  + self.configconfig[CONF_VIDEO_PROFILE_NAMES][
419  int.from_bytes(stream_config["v_profile_id"], byteorder="big")
420  ]
421  + " "
422  )
423  audio_application = ""
424  if self.configconfig[CONF_AUDIO_CODEC] == "libopus":
425  audio_application = "-application lowdelay "
426  output_vars = stream_config.copy()
427  output_vars.update(
428  {
429  "v_profile": video_profile,
430  "v_bufsize": stream_config["v_max_bitrate"] * 4,
431  "v_map": self.configconfig[CONF_VIDEO_MAP],
432  "v_pkt_size": self.configconfig[CONF_VIDEO_PACKET_SIZE],
433  "v_codec": self.configconfig[CONF_VIDEO_CODEC],
434  "a_bufsize": stream_config["a_max_bitrate"] * 4,
435  "a_map": self.configconfig[CONF_AUDIO_MAP],
436  "a_pkt_size": self.configconfig[CONF_AUDIO_PACKET_SIZE],
437  "a_encoder": self.configconfig[CONF_AUDIO_CODEC],
438  "a_application": audio_application,
439  }
440  )
441  output = VIDEO_OUTPUT.format(**output_vars)
442  if self.configconfig[CONF_SUPPORT_AUDIO]:
443  output = output + " " + AUDIO_OUTPUT.format(**output_vars)
444  _LOGGER.debug("FFmpeg output settings: %s", output)
445  stream = HAFFmpeg(self._ffmpeg_ffmpeg.binary)
446  opened = await stream.open(
447  cmd=[],
448  input_source=input_source,
449  output=output,
450  extra_cmd="-hide_banner -nostats",
451  stderr_pipe=True,
452  stdout_pipe=False,
453  )
454  if not opened:
455  _LOGGER.error("Failed to open ffmpeg stream")
456  return False
457 
458  _LOGGER.debug(
459  "[%s] Started stream process - PID %d",
460  session_info["id"],
461  stream.process.pid,
462  )
463 
464  session_info["stream"] = stream
465  session_info[FFMPEG_PID] = stream.process.pid
466 
467  stderr_reader = await stream.get_reader(source=FFMPEG_STDERR)
468 
469  async def watch_session(_: Any) -> None:
470  await self._async_ffmpeg_watch_async_ffmpeg_watch(session_info["id"])
471 
472  session_info[FFMPEG_LOGGER] = create_eager_task(
473  self._async_log_stderr_stream_async_log_stderr_stream(stderr_reader)
474  )
475  session_info[FFMPEG_WATCHER] = async_track_time_interval(
476  self.hasshass,
477  watch_session,
478  FFMPEG_WATCH_INTERVAL,
479  )
480 
481  return await self._async_ffmpeg_watch_async_ffmpeg_watch(session_info["id"])
482 
484  self, stderr_reader: asyncio.StreamReader
485  ) -> None:
486  """Log output from ffmpeg."""
487  _LOGGER.debug("%s: ffmpeg: started", self.display_name)
488  while True:
489  line = await stderr_reader.readline()
490  if line == b"":
491  return
492 
493  _LOGGER.debug("%s: ffmpeg: %s", self.display_name, line.rstrip())
494 
495  async def _async_ffmpeg_watch(self, session_id: str) -> bool:
496  """Check to make sure ffmpeg is still running and cleanup if not."""
497  ffmpeg_pid = self.sessions[session_id][FFMPEG_PID]
498  if pid_is_alive(ffmpeg_pid):
499  return True
500 
501  _LOGGER.warning("Streaming process ended unexpectedly - PID %d", ffmpeg_pid)
502  self._async_stop_ffmpeg_watch_async_stop_ffmpeg_watch(session_id)
503  self.set_streaming_available(self.sessions[session_id]["stream_idx"])
504  return False
505 
506  @callback
507  def _async_stop_ffmpeg_watch(self, session_id: str) -> None:
508  """Cleanup a streaming session after stopping."""
509  if FFMPEG_WATCHER not in self.sessions[session_id]:
510  return
511  self.sessions[session_id].pop(FFMPEG_WATCHER)()
512  self.sessions[session_id].pop(FFMPEG_LOGGER).cancel()
513 
514  @callback
515  def async_stop(self) -> None:
516  """Stop any streams when the accessory is stopped."""
517  for session_info in self.sessions.values():
518  self.hasshass.async_create_background_task(
519  self.stop_streamstop_stream(session_info), "homekit.camera-stop-stream"
520  )
521  super().async_stop()
522 
523  async def stop_stream(self, session_info: dict[str, Any]) -> None:
524  """Stop the stream for the given ``session_id``."""
525  session_id = session_info["id"]
526  if not (stream := session_info.get("stream")):
527  _LOGGER.debug("No stream for session ID %s", session_id)
528  return
529 
530  self._async_stop_ffmpeg_watch_async_stop_ffmpeg_watch(session_id)
531 
532  if not pid_is_alive(stream.process.pid):
533  _LOGGER.warning("[%s] Stream already stopped", session_id)
534  return
535 
536  for shutdown_method in ("close", "kill"):
537  _LOGGER.debug("[%s] %s stream", session_id, shutdown_method)
538  try:
539  await getattr(stream, shutdown_method)()
540  except Exception:
541  _LOGGER.exception(
542  "[%s] Failed to %s stream", session_id, shutdown_method
543  )
544  else:
545  return
546 
548  self, session_info: dict[str, Any], stream_config: dict[str, Any]
549  ) -> bool:
550  """Reconfigure the stream so that it uses the given ``stream_config``."""
551  return True
552 
553  async def async_get_snapshot(self, image_size: dict[str, int]) -> bytes:
554  """Return a jpeg of a snapshot from the camera."""
555  image = await camera.async_get_image(
556  self.hasshass,
557  self.entity_identity_id,
558  width=image_size["image-width"],
559  height=image_size["image-height"],
560  )
561  return image.content
bytes async_get_snapshot(self, dict[str, int] image_size)
bool start_stream(self, dict[str, Any] session_info, dict[str, Any] stream_config)
bool reconfigure_stream(self, dict[str, Any] session_info, dict[str, Any] stream_config)
None _async_update_motion_state(self, State|None old_state, State new_state)
None _async_update_doorbell_state_event(self, Event[EventStateChangedData] event)
None _async_update_doorbell_state(self, State|None old_state, State new_state)
None __init__(self, HomeAssistant hass, HomeDriver driver, str name, str entity_id, int aid, dict[str, Any] config)
None async_update_state(self, State|None new_state)
None stop_stream(self, dict[str, Any] session_info)
None _async_update_motion_state_event(self, Event[EventStateChangedData] event)
None _async_log_stderr_stream(self, asyncio.StreamReader stderr_reader)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
FFmpegManager get_ffmpeg_manager(HomeAssistant hass)
Definition: __init__.py:106
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679