1 """Class to hold all camera accessories."""
4 from datetime
import timedelta
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,
14 from pyhap.const
import CATEGORY_CAMERA
15 from pyhap.util
import callback
as pyhap_callback
22 EventStateChangedData,
29 async_track_state_change_event,
30 async_track_time_interval,
34 from .accessories
import TYPES, HomeAccessory, HomeDriver
38 CHAR_PROGRAMMABLE_SWITCH_EVENT,
41 CONF_AUDIO_PACKET_SIZE,
42 CONF_LINKED_DOORBELL_SENSOR,
43 CONF_LINKED_MOTION_SENSOR,
53 CONF_VIDEO_PACKET_SIZE,
54 CONF_VIDEO_PROFILE_NAMES,
57 DEFAULT_AUDIO_PACKET_SIZE,
62 DEFAULT_SUPPORT_AUDIO,
65 DEFAULT_VIDEO_PACKET_SIZE,
66 DEFAULT_VIDEO_PROFILE_NAMES,
70 SERV_STATELESS_PROGRAMMABLE_SWITCH,
72 from .util
import pid_is_alive, state_changed_event_is_same_state
74 _LOGGER = logging.getLogger(__name__)
76 DOORBELL_SINGLE_PRESS = 0
77 DOORBELL_DOUBLE_PRESS = 1
78 DOORBELL_LONG_PRESS = 2
84 "-tune zerolatency -pix_fmt yuv420p "
86 "-b:v {v_max_bitrate}k -bufsize {v_bufsize}k -maxrate {v_max_bitrate}k "
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}"
98 "-ac 1 -ar {a_sample_rate}k "
99 "-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
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}"
128 FFMPEG_LOGGER =
"ffmpeg_logger"
129 FFMPEG_WATCHER =
"ffmpeg_watcher"
130 FFMPEG_PID =
"ffmpeg_pid"
131 SESSION_ID =
"session_id"
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,
149 @TYPES.register("Camera")
153 """Generate a Camera accessory."""
162 config: dict[str, Any],
164 """Initialize a Camera accessory object."""
166 for config_key, conf
in CONFIG_DEFAULTS.items():
167 if config_key
not in config:
168 config[config_key] = conf
170 max_fps = config[CONF_MAX_FPS]
171 max_width = config[CONF_MAX_WIDTH]
172 max_height = config[CONF_MAX_HEIGHT]
175 for w, h, fps
in SLOW_RESOLUTIONS
176 if w <= max_width
and h <= max_height
and fps < max_fps
179 for w, h
in RESOLUTIONS
180 if w <= max_width
and h <= max_height
186 VIDEO_CODEC_PARAM_PROFILE_ID_TYPES[
"BASELINE"],
187 VIDEO_CODEC_PARAM_PROFILE_ID_TYPES[
"MAIN"],
188 VIDEO_CODEC_PARAM_PROFILE_ID_TYPES[
"HIGH"],
191 VIDEO_CODEC_PARAM_LEVEL_TYPES[
"TYPE3_1"],
192 VIDEO_CODEC_PARAM_LEVEL_TYPES[
"TYPE3_2"],
193 VIDEO_CODEC_PARAM_LEVEL_TYPES[
"TYPE4_0"],
196 "resolutions": resolutions,
200 {
"type":
"OPUS",
"samplerate": 24},
201 {
"type":
"OPUS",
"samplerate": 16},
205 stream_address = config.get(CONF_STREAM_ADDRESS, driver.state.address)
208 "video": video_options,
209 "audio": audio_options,
210 "address": stream_address,
212 "stream_count": config[CONF_STREAM_COUNT],
222 category=CATEGORY_CAMERA,
227 self.linked_motion_sensor: str |
None = self.
configconfig.
get(
228 CONF_LINKED_MOTION_SENSOR
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)
236 CHAR_MOTION_DETECTED, value=
False
242 linked_doorbell_sensor: str |
None = self.
configconfig.
get(
243 CONF_LINKED_DOORBELL_SENSOR
247 if not linked_doorbell_sensor:
249 self.
doorbell_is_eventdoorbell_is_event = linked_doorbell_sensor.startswith(
"event.")
250 if not (state := self.
hasshass.states.get(linked_doorbell_sensor)):
252 serv_doorbell = self.add_preload_service(SERV_DOORBELL)
253 self.set_primary_service(serv_doorbell)
255 CHAR_PROGRAMMABLE_SWITCH_EVENT,
258 serv_stateless_switch = self.add_preload_service(
259 SERV_STATELESS_PROGRAMMABLE_SWITCH
262 CHAR_PROGRAMMABLE_SWITCH_EVENT,
264 valid_values={
"SinglePress": DOORBELL_SINGLE_PRESS},
266 serv_speaker = self.add_preload_service(SERV_SPEAKER)
267 serv_speaker.configure_char(CHAR_MUTE, value=0)
273 """Handle accessory driver started event.
275 Run inside the Home Assistant event loop.
278 assert self.linked_motion_sensor
279 self._subscriptions.append(
282 self.linked_motion_sensor,
284 job_type=HassJobType.Callback,
290 self._subscriptions.append(
295 job_type=HassJobType.Callback,
303 self, event: Event[EventStateChangedData]
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"]
313 self, old_state: State |
None, new_state: State
315 """Handle link motion sensor state change to update HomeKit value."""
316 state = new_state.state
318 assert char
is not None
322 or old_state.state == STATE_UNAVAILABLE
323 or state
in (STATE_UNKNOWN, STATE_UNAVAILABLE)
327 "%s: Set linked motion %s sensor to True/False",
329 self.linked_motion_sensor,
332 char.set_value(
False)
335 detected = state == STATE_ON
336 if char.value == detected:
339 char.set_value(detected)
341 "%s: Set linked motion %s sensor to %d",
343 self.linked_motion_sensor,
349 self, event: Event[EventStateChangedData]
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"]
359 self, old_state: State |
None, new_state: State
361 """Handle link doorbell sensor state change to update HomeKit value."""
364 state = new_state.state
365 if state == STATE_ON
or (
367 and old_state
is not None
368 and old_state.state != STATE_UNAVAILABLE
369 and state
not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
374 "%s: Set linked doorbell %s sensor to %d",
377 DOORBELL_SINGLE_PRESS,
382 """Handle state change to update HomeKit value."""
385 """Find the camera stream source url."""
386 stream_source: str |
None = self.
configconfig.
get(CONF_STREAM_SOURCE)
390 stream_source = await camera.async_get_stream_source(
395 "Failed to get stream source - this could be a transient error or your"
396 " camera might not be compatible with HomeKit yet"
401 self, session_info: dict[str, Any], stream_config: dict[str, Any]
403 """Start a new stream with the given configuration."""
405 "[%s] Starting stream with the following parameters: %s",
410 _LOGGER.error(
"Camera has no stream source")
412 if "-i " not in input_source:
413 input_source =
"-i " + input_source
415 if self.
configconfig[CONF_VIDEO_CODEC] !=
"copy":
418 + self.
configconfig[CONF_VIDEO_PROFILE_NAMES][
419 int.from_bytes(stream_config[
"v_profile_id"], byteorder=
"big")
423 audio_application =
""
424 if self.
configconfig[CONF_AUDIO_CODEC] ==
"libopus":
425 audio_application =
"-application lowdelay "
426 output_vars = stream_config.copy()
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,
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(
448 input_source=input_source,
450 extra_cmd=
"-hide_banner -nostats",
455 _LOGGER.error(
"Failed to open ffmpeg stream")
459 "[%s] Started stream process - PID %d",
464 session_info[
"stream"] = stream
465 session_info[FFMPEG_PID] = stream.process.pid
467 stderr_reader = await stream.get_reader(source=FFMPEG_STDERR)
469 async
def watch_session(_: Any) ->
None:
472 session_info[FFMPEG_LOGGER] = create_eager_task(
478 FFMPEG_WATCH_INTERVAL,
484 self, stderr_reader: asyncio.StreamReader
486 """Log output from ffmpeg."""
487 _LOGGER.debug(
"%s: ffmpeg: started", self.display_name)
489 line = await stderr_reader.readline()
493 _LOGGER.debug(
"%s: ffmpeg: %s", self.display_name, line.rstrip())
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):
501 _LOGGER.warning(
"Streaming process ended unexpectedly - PID %d", ffmpeg_pid)
503 self.set_streaming_available(self.sessions[session_id][
"stream_idx"])
508 """Cleanup a streaming session after stopping."""
509 if FFMPEG_WATCHER
not in self.sessions[session_id]:
511 self.sessions[session_id].pop(FFMPEG_WATCHER)()
512 self.sessions[session_id].pop(FFMPEG_LOGGER).cancel()
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"
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)
532 if not pid_is_alive(stream.process.pid):
533 _LOGGER.warning(
"[%s] Stream already stopped", session_id)
536 for shutdown_method
in (
"close",
"kill"):
537 _LOGGER.debug(
"[%s] %s stream", session_id, shutdown_method)
539 await getattr(stream, shutdown_method)()
542 "[%s] Failed to %s stream", session_id, shutdown_method
548 self, session_info: dict[str, Any], stream_config: dict[str, Any]
550 """Reconfigure the stream so that it uses the given ``stream_config``."""
554 """Return a jpeg of a snapshot from the camera."""
555 image = await camera.async_get_image(
558 width=image_size[
"image-width"],
559 height=image_size[
"image-height"],
bytes async_get_snapshot(self, dict[str, int] image_size)
bool start_stream(self, dict[str, Any] session_info, dict[str, Any] stream_config)
str|None _async_get_stream_source(self)
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_stop_ffmpeg_watch(self, str session_id)
bool _async_ffmpeg_watch(self, str session_id)
None _async_update_motion_state_event(self, Event[EventStateChangedData] event)
_char_doorbell_detected_switch
None _async_log_stderr_stream(self, asyncio.StreamReader stderr_reader)
web.Response get(self, web.Request request, str config_key)
FFmpegManager get_ffmpeg_manager(HomeAssistant hass)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
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)