diff --git a/README.md b/README.md index 9dfcdd8..2876078 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ stream with two producers: ```yaml xmeye__ch_camera: - - rtsp://:554/user=…&password=…&channel=N&stream=0.sdp # H.265, passthrough - - ffmpeg:xmeye__ch_camera#video=h264#audio=opus # H.264, on demand + - rtsp://:554/user=…&password=…&channel=N&stream=0.sdp # H.265, passthrough + - ffmpeg:xmeye__ch_camera#video=h264#audio=opus#bitrate=2048k # H.264, on demand ``` go2rtc walks the producers in order and only dials the second one when the first one's @@ -103,6 +103,22 @@ audio-only variant — its provider skips the rewrite when the stream already ex of its producers matches the camera's stream source. The registration is re-asserted on every `stream_source()` call, so it self-heals. +### Options + +Both settings live in the integration's options (⚙️ on the device page) and apply per +device. Changing either reloads the entry and re-registers the streams. + +- **Transcode H.265 to H.264** (default on). Turn it off and the integration registers + nothing with go2rtc, leaving H.265 handling to Home Assistant's defaults — live view then + only works in clients that decode HEVC. The repair notices disappear too, since the + trade-off was made deliberately. +- **H.264 transcode bitrate cap** (default 2048 kbit/s). go2rtc's built-in h264 template + carries no rate control, so x264 falls back to CRF 23 and spends bits preserving the + compression artefacts the DVR already introduced. On a 2560×1440 main stream that + measured **6.6 Mbit/s out of a 0.73 Mbit/s H.265 source** — nine times the input. The cap + becomes `-b:v/-maxrate/-bufsize` on the encoder. Raise it if the picture looks soft on + busy scenes; it only affects the transcoded path, never what HEVC-capable clients receive. + Notes: - **Any go2rtc works.** The integration reuses the connection the `go2rtc` integration diff --git a/custom_components/xmeye/camera.py b/custom_components/xmeye/camera.py index efc5f17..4d2a5f5 100644 --- a/custom_components/xmeye/camera.py +++ b/custom_components/xmeye/camera.py @@ -31,6 +31,10 @@ CODEC_H264, CODEC_H265, CODEC_UNKNOWN, + CONF_TRANSCODE_BITRATE, + CONF_TRANSCODE_H265, + DEFAULT_TRANSCODE_BITRATE, + DEFAULT_TRANSCODE_H265, DOMAIN, REPAIR_H265_NO_GO2RTC, REPAIR_H265_TRANSCODING, @@ -446,10 +450,22 @@ async def _maybe_register_go2rtc(self) -> None: """Arm go2rtc transcoding if the chosen stream turned out to be H.265.""" if self._stream_url is None or self._codec != CODEC_H265: return + options = self._coordinator.entry.options + if not options.get(CONF_TRANSCODE_H265, DEFAULT_TRANSCODE_H265): + _LOGGER.info( + "ch%d is H.265 but transcoding is disabled for this device — " + "leaving the stream to Home Assistant's defaults", + self._channel + 1, + ) + # Registers nothing; called only so any advisory left over from + # before it was switched off gets cleared. + await self._ensure_go2rtc_stream(self._stream_url) + return _LOGGER.info( "ch%d is H.265 — registering with go2rtc (H.265 passthrough + " - "on-demand H.264 transcode)", + "on-demand H.264 transcode capped at %d kbit/s)", self._channel + 1, + int(options.get(CONF_TRANSCODE_BITRATE, DEFAULT_TRANSCODE_BITRATE)), ) if not await self._ensure_go2rtc_stream(self._stream_url): _LOGGER.debug( @@ -467,7 +483,7 @@ async def _ensure_go2rtc_stream(self, url: str) -> bool: 1. the DVR's RTSP URL — H.265 handed through untouched to clients that can decode it (VLC, the mobile apps, Safari); - 2. ``ffmpeg:#video=h264#audio=opus`` — go2rtc's + 2. ``ffmpeg:#video=h264#audio=opus#bitrate=…`` — go2rtc's ``AddConsumer`` walks producers in order and only dials this one when the previous producer's codecs do not match the consumer, so the transcode costs nothing until a browser without HEVC asks. @@ -478,10 +494,27 @@ async def _ensure_go2rtc_stream(self, url: str) -> bool: see ``homeassistant/components/go2rtc/__init__.py``: ``_update_stream_source`` skips the rewrite when the stream exists and one of its producers matches ``await camera.stream_source()``. + + Returns False without touching go2rtc when the user has turned + transcoding off for this device. """ + options = self._coordinator.entry.options + if not options.get(CONF_TRANSCODE_H265, DEFAULT_TRANSCODE_H265): + # Deliberate choice by the user: register nothing and let HA + # handle the H.265 stream its own way. Dropping the channel also + # clears both advisories — nobody should be nagged about a + # trade-off they made on purpose. + if self._coordinator.h265_channels.pop(self._channel, None) is not None: + _refresh_h265_issues(self.hass, self._coordinator) + return False + client: Go2RTCClient | None = self._coordinator.go2rtc_client identifier = _go2rtc_identifier(self) - sources = [url, f"ffmpeg:{identifier}#video=h264#audio=opus"] + bitrate = int(options.get(CONF_TRANSCODE_BITRATE, DEFAULT_TRANSCODE_BITRATE)) + sources = [ + url, + f"ffmpeg:{identifier}#video=h264#audio=opus#bitrate={bitrate}k", + ] ok = False if client is not None: try: diff --git a/custom_components/xmeye/config_flow.py b/custom_components/xmeye/config_flow.py index b72fe50..7f307e9 100644 --- a/custom_components/xmeye/config_flow.py +++ b/custom_components/xmeye/config_flow.py @@ -33,14 +33,20 @@ CONF_DEVICE_TYPE, CONF_MOTION_CLEAR_DELAY, CONF_STORAGE_REFRESH_INTERVAL, + CONF_TRANSCODE_BITRATE, + CONF_TRANSCODE_H265, CONFIG_ENTRY_VERSION, DEFAULT_MOTION_CLEAR_DELAY, DEFAULT_PORT, DEFAULT_STORAGE_REFRESH_INTERVAL, + DEFAULT_TRANSCODE_BITRATE, + DEFAULT_TRANSCODE_H265, DEFAULT_USERNAME, DOMAIN, MAX_STORAGE_REFRESH_INTERVAL, + MAX_TRANSCODE_BITRATE, MIN_STORAGE_REFRESH_INTERVAL, + MIN_TRANSCODE_BITRATE, UDP_DISCOVERY_PORT, ) @@ -366,6 +372,8 @@ async def async_step_init( CONF_STORAGE_REFRESH_INTERVAL: int( user_input[CONF_STORAGE_REFRESH_INTERVAL] ), + CONF_TRANSCODE_H265: bool(user_input[CONF_TRANSCODE_H265]), + CONF_TRANSCODE_BITRATE: int(user_input[CONF_TRANSCODE_BITRATE]), }, ) @@ -376,6 +384,12 @@ async def async_step_init( current_interval = int( options.get(CONF_STORAGE_REFRESH_INTERVAL, DEFAULT_STORAGE_REFRESH_INTERVAL) ) + current_transcode = bool( + options.get(CONF_TRANSCODE_H265, DEFAULT_TRANSCODE_H265) + ) + current_bitrate = int( + options.get(CONF_TRANSCODE_BITRATE, DEFAULT_TRANSCODE_BITRATE) + ) return self.async_show_form( step_id="init", data_schema=vol.Schema({ @@ -399,5 +413,19 @@ async def async_step_init( mode=NumberSelectorMode.SLIDER, ) ), + vol.Optional( + CONF_TRANSCODE_H265, default=current_transcode + ): bool, + vol.Optional( + CONF_TRANSCODE_BITRATE, default=current_bitrate + ): NumberSelector( + NumberSelectorConfig( + min=MIN_TRANSCODE_BITRATE, + max=MAX_TRANSCODE_BITRATE, + step=256, + unit_of_measurement="kbit/s", + mode=NumberSelectorMode.SLIDER, + ) + ), }), ) diff --git a/custom_components/xmeye/const.py b/custom_components/xmeye/const.py index f7d4f47..d7d5c73 100644 --- a/custom_components/xmeye/const.py +++ b/custom_components/xmeye/const.py @@ -10,12 +10,26 @@ CONF_DEVICE_TYPE = "device_type" CONF_MOTION_CLEAR_DELAY = "motion_clear_delay" CONF_STORAGE_REFRESH_INTERVAL = "storage_refresh_interval" +CONF_TRANSCODE_H265 = "transcode_h265" +CONF_TRANSCODE_BITRATE = "transcode_bitrate" DEFAULT_MOTION_CLEAR_DELAY = 30 # seconds; 0 = disable debounce DEFAULT_STORAGE_REFRESH_INTERVAL = 300 # seconds (5 min) MIN_STORAGE_REFRESH_INTERVAL = 60 # 1 min MAX_STORAGE_REFRESH_INTERVAL = 3600 # 1 hour +# Whether to hand H.265 channels to go2rtc for on-demand H.264 transcoding. +DEFAULT_TRANSCODE_H265 = True + +# Bitrate cap for that transcode, in kbit/s. go2rtc's built-in h264 template +# carries no rate control at all, so x264 falls back to CRF 23 and spends bits +# preserving the compression artefacts the DVR already introduced — a 2K main +# stream measured 6.6 Mbit/s out of a 0.73 Mbit/s H.265 source. `#bitrate=` is +# applied after the codec args, so it overrides that. +DEFAULT_TRANSCODE_BITRATE = 2048 +MIN_TRANSCODE_BITRATE = 256 +MAX_TRANSCODE_BITRATE = 8192 + # DVRIP message IDs MSG_LOGIN = 1000 MSG_LOGIN_RSP = 1001 diff --git a/custom_components/xmeye/manifest.json b/custom_components/xmeye/manifest.json index 6a59e52..ee49081 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.1" + "version": "0.8.2" } diff --git a/custom_components/xmeye/strings.json b/custom_components/xmeye/strings.json index eec5576..9ba7dfa 100644 --- a/custom_components/xmeye/strings.json +++ b/custom_components/xmeye/strings.json @@ -58,7 +58,9 @@ "description": "Adjust behaviour for this device.", "data": { "motion_clear_delay": "Motion clear delay (0 = immediate)", - "storage_refresh_interval": "Storage (HDD) refresh interval" + "storage_refresh_interval": "Storage (HDD) refresh interval", + "transcode_h265": "Transcode H.265 to H.264 for browsers without HEVC", + "transcode_bitrate": "H.264 transcode bitrate cap" } } } diff --git a/custom_components/xmeye/translations/de.json b/custom_components/xmeye/translations/de.json index 2836397..bfa13b7 100644 --- a/custom_components/xmeye/translations/de.json +++ b/custom_components/xmeye/translations/de.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Verzögerung bis Bewegungsmeldung endet (0 = sofort)", - "storage_refresh_interval": "Aktualisierungsintervall für Speicher (HDD)" + "storage_refresh_interval": "Aktualisierungsintervall für Speicher (HDD)", + "transcode_bitrate": "Bitratenlimit der H.264-Transkodierung", + "transcode_h265": "H.265 für Browser ohne HEVC nach H.264 transkodieren" }, "description": "Verhalten dieses Geräts anpassen.", "title": "XMEye / Sofia Optionen" diff --git a/custom_components/xmeye/translations/en.json b/custom_components/xmeye/translations/en.json index 5ffc31e..b861fd3 100644 --- a/custom_components/xmeye/translations/en.json +++ b/custom_components/xmeye/translations/en.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Motion clear delay (0 = immediate)", - "storage_refresh_interval": "Storage (HDD) refresh interval" + "storage_refresh_interval": "Storage (HDD) refresh interval", + "transcode_bitrate": "H.264 transcode bitrate cap", + "transcode_h265": "Transcode H.265 to H.264 for browsers without HEVC" }, "description": "Adjust behaviour for this device.", "title": "XMEye / Sofia Options" diff --git a/custom_components/xmeye/translations/es.json b/custom_components/xmeye/translations/es.json index 818848e..2326d9b 100644 --- a/custom_components/xmeye/translations/es.json +++ b/custom_components/xmeye/translations/es.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Retraso al limpiar detección de movimiento (0 = inmediato)", - "storage_refresh_interval": "Intervalo de actualización del almacenamiento (HDD)" + "storage_refresh_interval": "Intervalo de actualización del almacenamiento (HDD)", + "transcode_bitrate": "Límite de bitrate de la transcodificación H.264", + "transcode_h265": "Transcodificar H.265 a H.264 para navegadores sin HEVC" }, "description": "Ajuste el comportamiento de este dispositivo.", "title": "Opciones de XMEye / Sofia" diff --git a/custom_components/xmeye/translations/fr.json b/custom_components/xmeye/translations/fr.json index c83150f..4ca2ae6 100644 --- a/custom_components/xmeye/translations/fr.json +++ b/custom_components/xmeye/translations/fr.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Délai avant fin de la détection de mouvement (0 = immédiat)", - "storage_refresh_interval": "Intervalle de rafraîchissement du stockage (HDD)" + "storage_refresh_interval": "Intervalle de rafraîchissement du stockage (HDD)", + "transcode_bitrate": "Plafond de débit du transcodage H.264", + "transcode_h265": "Transcoder le H.265 en H.264 pour les navigateurs sans HEVC" }, "description": "Ajustez le comportement de cet appareil.", "title": "Options XMEye / Sofia" diff --git a/custom_components/xmeye/translations/it.json b/custom_components/xmeye/translations/it.json index 9f00fac..36923ac 100644 --- a/custom_components/xmeye/translations/it.json +++ b/custom_components/xmeye/translations/it.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Ritardo di chiusura rilevamento movimento (0 = immediato)", - "storage_refresh_interval": "Intervallo di aggiornamento archiviazione (HDD)" + "storage_refresh_interval": "Intervallo di aggiornamento archiviazione (HDD)", + "transcode_bitrate": "Limite di bitrate della transcodifica H.264", + "transcode_h265": "Transcodifica H.265 in H.264 per i browser senza HEVC" }, "description": "Regolare il comportamento di questo dispositivo.", "title": "Opzioni XMEye / Sofia" diff --git a/custom_components/xmeye/translations/ja.json b/custom_components/xmeye/translations/ja.json index b2090d5..2c8979d 100644 --- a/custom_components/xmeye/translations/ja.json +++ b/custom_components/xmeye/translations/ja.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "モーション検知解除の遅延 (0 = 即時)", - "storage_refresh_interval": "ストレージ(HDD)更新間隔" + "storage_refresh_interval": "ストレージ(HDD)更新間隔", + "transcode_bitrate": "H.264 トランスコードのビットレート上限", + "transcode_h265": "HEVC 非対応ブラウザー向けに H.265 を H.264 へトランスコード" }, "description": "このデバイスの動作を調整します。", "title": "XMEye / Sofia オプション" diff --git a/custom_components/xmeye/translations/nl.json b/custom_components/xmeye/translations/nl.json index b0e389b..0b1c1f4 100644 --- a/custom_components/xmeye/translations/nl.json +++ b/custom_components/xmeye/translations/nl.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Vertraging bewegingsdetectie wissen (0 = onmiddellijk)", - "storage_refresh_interval": "Verversingsinterval opslag (HDD)" + "storage_refresh_interval": "Verversingsinterval opslag (HDD)", + "transcode_bitrate": "Bitratelimiet van de H.264-transcodering", + "transcode_h265": "H.265 naar H.264 transcoderen voor browsers zonder HEVC" }, "description": "Pas het gedrag van dit apparaat aan.", "title": "XMEye / Sofia opties" diff --git a/custom_components/xmeye/translations/pl.json b/custom_components/xmeye/translations/pl.json index 114b3dc..cfce11a 100644 --- a/custom_components/xmeye/translations/pl.json +++ b/custom_components/xmeye/translations/pl.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Opóźnienie czyszczenia wykrycia ruchu (0 = natychmiast)", - "storage_refresh_interval": "Interwał odświeżania pamięci masowej (HDD)" + "storage_refresh_interval": "Interwał odświeżania pamięci masowej (HDD)", + "transcode_bitrate": "Limit bitrate'u transkodowania H.264", + "transcode_h265": "Transkoduj H.265 na H.264 dla przeglądarek bez HEVC" }, "description": "Dostosuj zachowanie tego urządzenia.", "title": "Opcje XMEye / Sofia" diff --git a/custom_components/xmeye/translations/pt.json b/custom_components/xmeye/translations/pt.json index dc5f307..2f6a8b9 100644 --- a/custom_components/xmeye/translations/pt.json +++ b/custom_components/xmeye/translations/pt.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Delay para limpar movimento (0 = imediato)", - "storage_refresh_interval": "Intervalo de atualização do storage (HDD)" + "storage_refresh_interval": "Intervalo de atualização do storage (HDD)", + "transcode_bitrate": "Teto de bitrate do transcode H.264", + "transcode_h265": "Transcodificar H.265 para H.264 em navegadores sem HEVC" }, "description": "Ajuste o comportamento deste dispositivo.", "title": "Opções XMEye / Sofia" diff --git a/custom_components/xmeye/translations/ru.json b/custom_components/xmeye/translations/ru.json index 5ca2458..dd12c4e 100644 --- a/custom_components/xmeye/translations/ru.json +++ b/custom_components/xmeye/translations/ru.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Задержка сброса детекции движения (0 = мгновенно)", - "storage_refresh_interval": "Интервал обновления информации о хранилище (HDD)" + "storage_refresh_interval": "Интервал обновления информации о хранилище (HDD)", + "transcode_bitrate": "Ограничение битрейта перекодирования H.264", + "transcode_h265": "Перекодировать H.265 в H.264 для браузеров без HEVC" }, "description": "Настройка поведения для этого устройства.", "title": "Параметры XMEye / Sofia" diff --git a/custom_components/xmeye/translations/tr.json b/custom_components/xmeye/translations/tr.json index 2d642e9..13c9b58 100644 --- a/custom_components/xmeye/translations/tr.json +++ b/custom_components/xmeye/translations/tr.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "Hareket algılama temizleme gecikmesi (0 = anında)", - "storage_refresh_interval": "Depolama (HDD) yenileme aralığı" + "storage_refresh_interval": "Depolama (HDD) yenileme aralığı", + "transcode_bitrate": "H.264 dönüştürme bit hızı üst sınırı", + "transcode_h265": "HEVC desteklemeyen tarayıcılar için H.265'i H.264'e dönüştür" }, "description": "Bu cihazın davranışını ayarlayın.", "title": "XMEye / Sofia Seçenekleri" diff --git a/custom_components/xmeye/translations/zh-Hans.json b/custom_components/xmeye/translations/zh-Hans.json index 447bd30..0f78633 100644 --- a/custom_components/xmeye/translations/zh-Hans.json +++ b/custom_components/xmeye/translations/zh-Hans.json @@ -144,7 +144,9 @@ "init": { "data": { "motion_clear_delay": "移动侦测清除延迟 (0 = 立即)", - "storage_refresh_interval": "存储设备(硬盘)刷新间隔" + "storage_refresh_interval": "存储设备(硬盘)刷新间隔", + "transcode_bitrate": "H.264 转码码率上限", + "transcode_h265": "为不支持 HEVC 的浏览器把 H.265 转码为 H.264" }, "description": "调整此设备的行为。", "title": "XMEye / Sofia 选项" diff --git a/docs/STREAM_INVESTIGATION.md b/docs/STREAM_INVESTIGATION.md index 2f37da5..209a6b5 100644 --- a/docs/STREAM_INVESTIGATION.md +++ b/docs/STREAM_INVESTIGATION.md @@ -2,7 +2,7 @@ **Data:** 2026-06-16 **Device:** HVR XMEye, 9 canais, host `192.168.16.10` (DVRIP porta 34567, RTSP 554) -**Status:** Causa raiz identificada e confirmada. Correção pendente (escolha de remediação). +**Status:** Resolvido nas v0.8.0/v0.8.1; teto de bitrate do transcode na v0.8.2. Ver §8–§10. --- @@ -285,3 +285,53 @@ await document.querySelector("home-assistant").hass ``` Antes: `{frontend_stream_types: ["hls"]}`. Depois: `["hls", "webrtc"]`. + +--- + +## 10. Ajuste (v0.8.2) — o transcode gastava 9× o bitrate da origem + +Com tudo funcionando, uma inspeção ao vivo do `/api/streams` (câmera aberta no Chrome) +mostrou um número ruim. Amostrando `bytes_recv` dos dois producers do `ch0` com 20 s de +intervalo: + +| | | +|---|---| +| Origem H.265 do DVR | **0,73 Mbps** @ 2560×1440 | +| Producer H.264 transcodificado | **6,60 Mbps** @ 2560×1440 | + +Resolução obtida decodificando o `sprop-parameter-sets` do SDP do producer transcodificado. + +Causa: o template `h264` embutido do go2rtc (`internal/ffmpeg/ffmpeg.go`) é + +``` +-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p +``` + +— **sem controle de taxa nenhum**. O x264 cai no CRF 23 padrão e gasta bits tentando +preservar os artefatos de compressão que o próprio DVR já introduziu. Agravante: o consumer +WebRTC observado estava num IPv6 público, ou seja, 6,6 Mbps saindo pela internet para +assistir a uma câmera cuja origem cabe em 0,73 Mbps. + +O `parseArgs` do go2rtc aceita `#bitrate=`, aplicado **depois** dos args de codec: + +```go +if query["bitrate"] != nil { + b := query["bitrate"][0] + args.AddCodec("-b:v " + b + " -maxrate " + b + " -bufsize " + b) +} +``` + +Correção: a fonte do ffmpeg passa a ser +`ffmpeg:#video=h264#audio=opus#bitrate=k`, com `N` configurável por device no fluxo +de opções (slider, 256–8192 kbit/s, padrão 2048). Junto veio um toggle para desligar o +transcode por completo — nesse caso não registramos nada no go2rtc e os dois repair issues +somem, porque a escolha foi deliberada. + +Resolução não é alterada de propósito: quem decodifica HEVC continua recebendo os +2560×1440 originais, e o teto vale só para o caminho do browser. + +Nota sobre o `_stream_has_sources`: o sufixo novo não quebra o match. A URL do producer +conectado é +`rtsp://127.0.0.1:18554/?video&audio&source=ffmpeg:%23video%3Dh264%23audio%3Dopus…`, +que depois do `unquote` contém a string da fonte literalmente, sufixo incluído — verificado +em campo. Trocar a opção muda a string, o match falha de propósito, e o PUT reescreve.