From 04690e093953de942e600f9cfd02445d9f3b212c Mon Sep 17 00:00:00 2001 From: Marcos Araujo Sobrinho Date: Sun, 26 Jul 2026 18:59:14 -0300 Subject: [PATCH] fix(camera): attach the WebRTC provider once the stream source is known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.8.0 registered the go2rtc streams correctly but nothing ever consumed them: Companion played H.265 directly and Chrome fell back to the still-image proxy at ~1 fps, both after a few seconds of buffering — the HLS path, not WebRTC. Camera.async_refresh_providers() only runs from async_internal_added_to_hass, when a provider registers/unregisters, or when the STREAM feature bit flips. At entity-add time XMEyeCoordinator.async_setup() has only scheduled _connection_loop() as a background task, so coordinator.connected is still False — and stream_source() returned None on exactly that condition. async_get_supported_provider() therefore got None, no provider was attached, and camera_capabilities stayed HLS-only for the life of the entity. The after_dependencies on go2rtc added in v0.8.0 made this worse by removing the second chance the provider's own registration used to give. - Drop the `connected` guard from stream_source(); RTSP does not go through the DVRIP alarm socket. - Refresh providers at the end of _probe_urls(), once the real URL and codec are known. - Refresh providers when privacy is toggled, since privacy nulls the stream source and HA does not re-evaluate that on its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011UpaDKeLbpQB7EapQBpKoJ --- custom_components/xmeye/camera.py | 31 ++++++++++++++++++-- custom_components/xmeye/manifest.json | 2 +- docs/STREAM_INVESTIGATION.md | 41 +++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/custom_components/xmeye/camera.py b/custom_components/xmeye/camera.py index 4ade073..efc5f17 100644 --- a/custom_components/xmeye/camera.py +++ b/custom_components/xmeye/camera.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -363,6 +363,9 @@ def __init__(self, coordinator: XMEyeCoordinator, channel: int) -> None: # registered stream name; None otherwise. Used to clean up on # entity removal and to switch stream_source() to the go2rtc URL. self._go2rtc_stream_name: str | None = None + # Privacy nulls stream_source(), which drops the WebRTC provider; + # tracked so we can ask HA to re-evaluate when it is lifted. + self._was_private = channel in coordinator.private_channels # Locks prevent concurrent probes if HA calls us before the background task finishes. self._snapshot_lock = asyncio.Lock() @@ -410,6 +413,16 @@ async def async_will_remove_from_hass(self) -> None: if self._coordinator.h265_channels.pop(self._channel, None) is not None: _refresh_h265_issues(self.hass, self._coordinator) + @callback + def _handle_update(self) -> None: + # Leaving privacy gives the camera a stream source again. HA does not + # re-evaluate WebRTC providers for that on its own, so nudge it. + private = self._channel in self._coordinator.private_channels + if private != self._was_private: + self._was_private = private + self.hass.async_create_task(self.async_refresh_providers()) + super()._handle_update() + async def _probe_urls(self) -> None: """Probe snapshot and RTSP URLs concurrently at startup, then arm go2rtc transcoding if the chosen stream is H.265-only.""" @@ -420,6 +433,14 @@ async def _probe_urls(self) -> None: self._find_stream_url(), ) await self._maybe_register_go2rtc() + # HA decided whether to attach a WebRTC provider back in + # async_internal_added_to_hass, before any of the above had run, and + # it only re-evaluates on its own when a provider registers or the + # STREAM feature bit flips. Ask it to look again now that there is a + # real stream source — otherwise the camera stays HLS-only and + # browsers that cannot decode H.265 fall back to the still-image + # proxy at roughly one frame per second. + await self.async_refresh_providers() async def _maybe_register_go2rtc(self) -> None: """Arm go2rtc transcoding if the chosen stream turned out to be H.265.""" @@ -650,6 +671,12 @@ def _default_stream_url(self) -> str: async def stream_source(self) -> str | None: """Return the DVR's RTSP URL for the live view. + Never gated on ``coordinator.connected``: RTSP does not go through + the DVRIP alarm socket, and HA asks for the stream source while + deciding whether to attach a WebRTC provider — which happens before + the coordinator's background connection loop has logged in. Returning + None there would leave the camera HLS-only for the rest of its life. + Always the camera's own URL — never a go2rtc loopback URL. Chaining go2rtc through its own RTSP server collapses the codec negotiation (an RTSP consumer takes the first matching video track and stops @@ -662,8 +689,6 @@ async def stream_source(self) -> str | None: two producers here means the state is already correct by the time it looks — and self-heals if anything ever replaced it. """ - if not self._coordinator.connected: - return None if self._channel in self._coordinator.private_channels: return None url = self._stream_url or self._default_stream_url() diff --git a/custom_components/xmeye/manifest.json b/custom_components/xmeye/manifest.json index a13db89..6a59e52 100644 --- a/custom_components/xmeye/manifest.json +++ b/custom_components/xmeye/manifest.json @@ -11,5 +11,5 @@ "issue_tracker": "https://github.com/equake/hass-xmeye/issues", "quality_scale": "silver", "requirements": [], - "version": "0.8.0" + "version": "0.8.1" } diff --git a/docs/STREAM_INVESTIGATION.md b/docs/STREAM_INVESTIGATION.md index e34db45..2f37da5 100644 --- a/docs/STREAM_INVESTIGATION.md +++ b/docs/STREAM_INVESTIGATION.md @@ -244,3 +244,44 @@ mexe no encoder do usuário**. Passou a haver dois repair issues por device (nã Ambos explicam o trade-off: H.264 ocupa ~40–100 % mais disco por minuto que H.265 com qualidade equivalente (perto do dobro em cenas com muito movimento; ~15–25 % em baixa resolução ou cenas quase estáticas). + +--- + +## 9. Follow-up (v0.8.1) — o provider WebRTC nunca era anexado + +Com a v0.8.0 o registro no go2rtc passou a acontecer corretamente (config confirmada em +campo, os três canais H.265 com os dois producers cada), mas **nenhum cliente consumia via +go2rtc**: Companion abria em H.265 direto e Chrome caía no still-image proxy (~1 fps), os +dois com alguns segundos de espera — assinatura clássica do caminho HLS. + +Causa: `Camera.async_refresh_providers()` só roda em `async_internal_added_to_hass()`, e +quando um provider se registra/desregistra, ou quando o bit `CameraEntityFeature.STREAM` +muda. Nesse primeiro momento: + +- `XMEyeCoordinator.async_setup()` dispara `_connection_loop()` como background task e + retorna imediatamente, então `coordinator.connected` ainda é `False`; +- `stream_source()` tinha o guard `if not self._coordinator.connected: return None`; +- `async_get_supported_provider()` recebe `None` e devolve `None` → `_webrtc_provider` + fica `None` → `camera_capabilities` = `{HLS}` para o resto da vida da entidade. + +Ironia: o `after_dependencies: [go2rtc]` adicionado na v0.8.0 piorou isso. Antes, se a +integração `go2rtc` subisse depois das câmeras, o `async_register_webrtc_provider()` dela +disparava `_async_refresh_providers(hass)` e dava uma segunda chance de anexar o provider. + +Correções: + +1. Removido o guard `connected` de `stream_source()` — o RTSP não passa pelo socket DVRIP + de alarme (era a pendência da §7). +2. `await self.async_refresh_providers()` no fim de `_probe_urls()`, quando já se conhece + a URL e o codec reais. +3. `_handle_update()` sobrescrito para re-avaliar providers quando o modo privacidade é + ligado/desligado — privacidade zera `stream_source()`, e o HA não reavalia sozinho. + +Como verificar rapidamente, no console do browser com o frontend do HA aberto: + +```js +await document.querySelector("home-assistant").hass + .callWS({type: "camera/capabilities", entity_id: "camera."}) +``` + +Antes: `{frontend_stream_types: ["hls"]}`. Depois: `["hls", "webrtc"]`.