diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 157f20b5b37..9ec389fb4a8 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -23,7 +23,7 @@ import secrets import threading import time from types import MappingProxyType -from typing import cast +from typing import Any, cast import voluptuous as vol @@ -51,6 +51,7 @@ from .const import ( TARGET_SEGMENT_DURATION_NON_LL_HLS, ) from .core import PROVIDERS, IdleTimer, KeyFrameConverter, StreamOutput, StreamSettings +from .diagnostics import Diagnostics from .hls import HlsStreamOutput, async_setup_hls _LOGGER = logging.getLogger(__name__) @@ -225,6 +226,7 @@ class Stream: if stream_label else _LOGGER ) + self._diagnostics = Diagnostics() def endpoint_url(self, fmt: str) -> str: """Start the stream and returns a url for the output format.""" @@ -259,6 +261,7 @@ class Stream: self.hass, IdleTimer(self.hass, timeout, idle_callback) ) self._outputs[fmt] = provider + return provider def remove_provider(self, provider: StreamOutput) -> None: @@ -310,6 +313,7 @@ class Stream: def update_source(self, new_source: str) -> None: """Restart the stream with a new stream source.""" + self._diagnostics.increment("update_source") self._logger.debug( "Updating stream source %s", redact_credentials(str(new_source)) ) @@ -323,11 +327,13 @@ class Stream: # pylint: disable=import-outside-toplevel from .worker import StreamState, StreamWorkerError, stream_worker - stream_state = StreamState(self.hass, self.outputs) + stream_state = StreamState(self.hass, self.outputs, self._diagnostics) wait_timeout = 0 while not self._thread_quit.wait(timeout=wait_timeout): start_time = time.time() self.hass.add_job(self._async_update_state, True) + self._diagnostics.set_value("keepalive", self.keepalive) + self._diagnostics.increment("start_worker") try: stream_worker( self.source, @@ -337,6 +343,7 @@ class Stream: self._thread_quit, ) except StreamWorkerError as err: + self._diagnostics.increment("worker_error") self._logger.error("Error from stream worker: %s", str(err)) stream_state.discontinuity() @@ -358,6 +365,7 @@ class Stream: if time.time() - start_time > STREAM_RESTART_RESET_TIME: wait_timeout = 0 wait_timeout += STREAM_RESTART_INCREMENT + self._diagnostics.set_value("retry_timeout", wait_timeout) self._logger.debug( "Restarting stream worker in %d seconds: %s", wait_timeout, @@ -447,6 +455,10 @@ class Stream: width=width, height=height ) + def get_diagnostics(self) -> dict[str, Any]: + """Return diagnostics information for the stream.""" + return self._diagnostics.as_dict() + def _should_retry() -> bool: """Return true if worker failures should be retried, for disabling during tests.""" diff --git a/homeassistant/components/stream/diagnostics.py b/homeassistant/components/stream/diagnostics.py new file mode 100644 index 00000000000..47370eeb5f9 --- /dev/null +++ b/homeassistant/components/stream/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics for debugging. + +The stream component does not have config entries itself, and all diagnostics +information is managed by dependent components (e.g. camera) +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + + +class Diagnostics: + """Holds diagnostics counters and key/values.""" + + def __init__(self) -> None: + """Initialize Diagnostics.""" + self._counter: Counter = Counter() + self._values: dict[str, Any] = {} + + def increment(self, key: str) -> None: + """Increment a counter for the spcified key/event.""" + self._counter.update(Counter({key: 1})) + + def set_value(self, key: str, value: Any) -> None: + """Update a key/value pair.""" + self._values[key] = value + + def as_dict(self) -> dict[str, Any]: + """Return diagnostics as a debug dictionary.""" + result = {k: self._counter[k] for k in self._counter} + result.update(self._values) + return result diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 42a34cf4e8c..bde5ab0fb05 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -27,6 +27,7 @@ from .const import ( SOURCE_TIMEOUT, ) from .core import KeyFrameConverter, Part, Segment, StreamOutput, StreamSettings +from .diagnostics import Diagnostics from .hls import HlsStreamOutput _LOGGER = logging.getLogger(__name__) @@ -52,6 +53,7 @@ class StreamState: self, hass: HomeAssistant, outputs_callback: Callable[[], Mapping[str, StreamOutput]], + diagnostics: Diagnostics, ) -> None: """Initialize StreamState.""" self._stream_id: int = 0 @@ -62,6 +64,7 @@ class StreamState: # sequence gets incremented before the first segment so the first segment # has a sequence number of 0. self._sequence = -1 + self._diagnostics = diagnostics @property def sequence(self) -> int: @@ -93,6 +96,11 @@ class StreamState: """Return the active stream outputs.""" return list(self._outputs_callback().values()) + @property + def diagnostics(self) -> Diagnostics: + """Return diagnostics object.""" + return self._diagnostics + class StreamMuxer: """StreamMuxer re-packages video/audio packets for output.""" @@ -468,6 +476,10 @@ def stream_worker( # Some audio streams do not have a profile and throw errors when remuxing if audio_stream and audio_stream.profile is None: audio_stream = None + stream_state.diagnostics.set_value("container_format", container.format.name) + stream_state.diagnostics.set_value("video_codec", video_stream.name) + if audio_stream: + stream_state.diagnostics.set_value("audio_codec", audio_stream.name) dts_validator = TimestampValidator() container_packets = PeekIterator( diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index a2bb328826d..8e01c55de84 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -177,6 +177,14 @@ async def test_hls_stream( fail_response = await hls_client.get() assert fail_response.status == HTTPStatus.NOT_FOUND + assert stream.get_diagnostics() == { + "container_format": "mov,mp4,m4a,3gp,3g2,mj2", + "keepalive": False, + "start_worker": 1, + "video_codec": "h264", + "worker_error": 1, + } + async def test_stream_timeout( hass, hass_client, setup_component, stream_worker_sync, h264_video diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index e5477c66f52..2a44dd64455 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -270,7 +270,7 @@ class MockPyAv: def run_worker(hass, stream, stream_source): """Run the stream worker under test.""" - stream_state = StreamState(hass, stream.outputs) + stream_state = StreamState(hass, stream.outputs, stream._diagnostics) stream_worker( stream_source, {}, stream_state, KeyFrameConverter(hass), threading.Event() ) @@ -873,6 +873,14 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): stream.stop() + assert stream.get_diagnostics() == { + "container_format": "mov,mp4,m4a,3gp,3g2,mj2", + "keepalive": False, + "start_worker": 1, + "video_codec": "hevc", + "worker_error": 1, + } + async def test_get_image(hass, record_worker_sync): """Test that the has_keyframe metadata matches the media."""