From f315fbd36df1fbdfe1d4a740e9c279f533ecccf8 Mon Sep 17 00:00:00 2001 From: MIGI10 <33965011+MIGI10@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:19:44 +0200 Subject: [PATCH] feat: audio switch so the physical handset keeps its speaker during live views Every live view made Ring route the intercom's incoming audio to the WebRTC session (live_view audio_enabled=true + camera_options stealth_mode=false, which ring-client-api calls activateCameraSpeaker), so the physical handset kept video but lost its speaker while a stream was open. Fine for the companion card, wrong for consumers that never play audio or don't need it: go2rtc/Frigate restreams, or a TV display that shows the visitor while the user talks on the physical handset. Add switch._audio (default on = current behaviour, state restored). Off makes live views video-only: live_view goes out with audio_enabled=false and camera_options with stealth_mode=true. Toggling while sessions run is applied in place via stream_options / camera_options session messages, no renegotiation. All sessions now go through an AudioGate websocket proxy on a RingWebRtcStream subclass; no library code is copied. Offers flagged video-only (session attribute "a=x-video-only", or all audio m-lines inactive / rejected) never take the audio regardless of the switch, which is how a go2rtc/WHEP bridge that cannot alter its own offer asks for it. Rewriting the audio m-line itself made Ring stop sending video, hence the out-of-band attribute. Tested on a Ring Intercom Handset Video (BTicino handset) with go2rtc (WHEP) as consumer: video streams, the handset keeps hearing the visitor. Co-Authored-By: Claude Opus 5 --- README.md | 28 ++++ .../ring_intercom_camera/__init__.py | 36 ++++- .../ring_intercom_camera/audio.py | 149 ++++++++++++++++++ .../ring_intercom_camera/switch.py | 111 +++++++++++++ 4 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 custom_components/ring_intercom_camera/audio.py create mode 100644 custom_components/ring_intercom_camera/switch.py diff --git a/README.md b/README.md index b2453f9..495fe1e 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,34 @@ You have two options: - 🎛️ **Use the companion [Ring Intercom Video Card](https://github.com/cmos486/ring-intercom-video-card)** *(recommended)* — full intercom UX with video + two‑way audio + open door + hang up. - 📷 **Use a built‑in card** — any Picture Entity or Camera card. When you click the live view button, WebRTC streaming starts automatically. (Video only — no two‑way audio.) +### Audio switch — keep the handset audio while the picture shows elsewhere + +Any live view normally makes Ring route the intercom's incoming audio to the WebRTC session, so +while the stream is open the physical handset shows video but its speaker goes silent. That is +what the companion card wants (it plays the audio), but not what you want when the picture is +consumed by something that never plays audio or does not need it — a go2rtc/Frigate restream, or +a TV/kiosk screen that shows the visitor while you still pick up the physical handset to talk. + +The component exposes **`switch._audio`** (default **on**, state restored across restarts): + +| switch | live view gets | physical handset | +|---|---|---| +| on (default) | video + audio | video, speaker muted while a session is open | +| off | video only | video + audio, as if nobody were watching | + +Turning it while a live view is open applies immediately — Ring accepts `stream_options` / +`camera_options` on a running session, so no reconnection and no gap in the picture. Known +limitation: turning it **off** mid‑session reliably mutes the live view, but the device occasionally +only hands the audio back to the handset when the session ends. A typical +setup: leave it **off**, and have the companion card's *pick up* button (or an automation) turn it +**on** to move the conversation to the phone, and **off** again on hang up. + +Independently of the switch, a WebRTC offer is always treated as video‑only when it carries a +session‑level attribute `a=x-video-only`, or when every audio m‑line is `a=inactive` / rejected +(port 0). That is how a go2rtc/WHEP bridge (which cannot change its own offer) asks for a session +that never takes the audio. With `logger: custom_components.ring_intercom_camera: debug` you will +see `WebRTC : audio=False` for such sessions. + --- ## 🧪 Technical details diff --git a/custom_components/ring_intercom_camera/__init__.py b/custom_components/ring_intercom_camera/__init__.py index 6fdfad3..493d17f 100644 --- a/custom_components/ring_intercom_camera/__init__.py +++ b/custom_components/ring_intercom_camera/__init__.py @@ -11,6 +11,12 @@ - When user opens the camera in Lovelace, the browser establishes WebRTC directly - Exposes a binary_sensor reporting whether a live view is open, because the device's single analog capture path allows only one consumer at a time +- Exposes a switch (default on) deciding whether live views take the + intercom audio; off means the physical handset keeps its speaker while the + picture shows elsewhere (TV, wallpanel). Toggling applies to running + sessions too, without renegotiation. Offers flagged video-only (session + attribute "a=x-video-only", or audio m-line inactive/rejected — go2rtc/WHEP + bridges) never take the audio regardless of the switch """ from __future__ import annotations @@ -25,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CAMERA, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SWITCH] def _patch_ring_other() -> None: @@ -38,9 +44,26 @@ def _patch_ring_other() -> None: from ring_doorbell.other import RingOther from ring_doorbell.webrtcstream import RingWebRtcStream + from .audio import AudioGate, get_audio_state, offer_wants_audio + if hasattr(RingOther, "generate_async_webrtc_stream"): return # Already patched + class _GatedRingWebRtcStream(RingWebRtcStream): + """RingWebRtcStream whose signaling goes through an AudioGate.""" + + audio: bool = True + + @property + def websocket(self): + return self.__dict__.get("_ws_gate") + + @websocket.setter + def websocket(self, ws): + self.__dict__["_ws_gate"] = ( + AudioGate(ws, self.audio) if ws is not None else None + ) + def _get_streams(self): """Lazy-init _webrtc_streams for already-instantiated objects.""" if not hasattr(self, "_webrtc_streams"): @@ -55,13 +78,18 @@ async def generate_async_webrtc_stream(self, sdp_offer, session_id, async def _close_callback(): await self.close_webrtc_stream(session_id) - stream = RingWebRtcStream( + # Audio goes to this session only if the device's audio switch is on + # AND the offer didn't flag itself video-only (go2rtc/WHEP bridges). + audio = get_audio_state(self.device_api_id).enabled and offer_wants_audio(sdp_offer) + _LOGGER.debug("WebRTC %s: audio=%s", session_id, audio) + stream = _GatedRingWebRtcStream( self._ring, self.device_api_id, on_message_callback=on_message_callback, keep_alive_timeout=keep_alive_timeout, on_close_callback=_close_callback, ) + stream.audio = audio streams[session_id] = stream await stream.generate(sdp_offer) @@ -95,8 +123,8 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: _patch_ring_other() - # Both platforms look up their per-device LiveSessionTracker from - # hass.data, so they can be loaded in any order. + # Platforms look up their per-device state (session tracker, audio state) + # lazily, so they can be loaded in any order. for platform in PLATFORMS: hass.async_create_task( discovery.async_load_platform(hass, platform, DOMAIN, {}, config) diff --git a/custom_components/ring_intercom_camera/audio.py b/custom_components/ring_intercom_camera/audio.py new file mode 100644 index 0000000..f82b24c --- /dev/null +++ b/custom_components/ring_intercom_camera/audio.py @@ -0,0 +1,149 @@ +"""Per-device audio policy for live-view sessions. + +Ring routes the intercom's incoming audio to whichever WebRTC session asks +for it (live_view audio_enabled=true + camera_options stealth_mode=false, +what ring-client-api calls activateCameraSpeaker). While that is the case +the physical handset still shows video but its speaker is silent. + +AudioState holds, per device, whether live views may take the audio. It is +keyed by device_api_id in a module-level registry rather than hass.data +because the RingOther methods patched in __init__ have no hass handle. + +Per-session override: an offer flagged video-only (session attribute +"a=x-video-only", or every audio m-line inactive / port 0) never takes the +audio, whatever the switch says. That is what a go2rtc/WHEP consumer uses. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +VIDEO_ONLY_ATTR = "a=x-video-only" + +_states: dict[int, "AudioState"] = {} + + +def get_audio_state(device_api_id: int) -> "AudioState": + """Return the AudioState for a device, creating it on first use.""" + return _states.setdefault(device_api_id, AudioState()) + + +class AudioState: + """Whether live-view sessions of one device may take the intercom audio.""" + + def __init__(self) -> None: + self.enabled: bool = True # default: current behaviour + self._listeners: list[Callable[[], None]] = [] + + def add_listener(self, listener: Callable[[], None]) -> Callable[[], None]: + self._listeners.append(listener) + + def _remove() -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + return _remove + + def set(self, enabled: bool) -> None: + if enabled == self.enabled: + return + self.enabled = enabled + for listener in list(self._listeners): + listener() + + +def offer_wants_audio(sdp_offer: str) -> bool: + """False for video-only offers, True otherwise (browser offers).""" + lines = sdp_offer.replace("\r\n", "\n").split("\n") + if VIDEO_ONLY_ATTR in lines: + return False + in_audio = False + for line in lines: + if line.startswith("m="): + if in_audio: + return True # previous audio section had a live direction + parts = line.split(" ") + in_audio = parts[0] == "m=audio" and parts[1] != "0" + elif in_audio and line == "a=inactive": + in_audio = False + return in_audio + + +class AudioGate: + """Websocket proxy that applies the audio policy to Ring's signaling. + + Rewrites exactly two outgoing messages: + - live_view -> stream_options.audio_enabled = audio + - camera_options -> stealth_mode = not audio + Everything else passes through untouched; reads are delegated. + """ + + def __init__(self, ws, audio: bool) -> None: + self._ws = ws + self.audio = audio + + async def send(self, data): + try: + msg = json.loads(data) + method = msg.get("method") + if method == "live_view": + msg["body"]["stream_options"]["audio_enabled"] = self.audio + data = json.dumps(msg) + _LOGGER.debug( + "live_view sent with stream_options=%s", + msg["body"]["stream_options"], + ) + elif method == "camera_options": + msg["body"]["stealth_mode"] = not self.audio + data = json.dumps(msg) + _LOGGER.debug("camera_options sent as %s", msg["body"]) + except (ValueError, KeyError, TypeError): + pass + return await self._ws.send(data) + + def __getattr__(self, name): + return getattr(self._ws, name) + + def __aiter__(self): + return self._ws.__aiter__() + + +async def apply_audio_live(stream, audio: bool) -> bool: + """Switch audio on/off on an already running session, no renegotiation. + + ring-client-api sends stream_options and camera_options as session + messages after activate_session, so the device accepts them while the + session runs. The browser's audio transceiver is recvonly from the start; + it simply plays whatever RTP arrives. + """ + ws = stream.websocket + if ws is None or not stream.is_alive or not stream.session_id: + return False + if isinstance(ws, AudioGate): + ws.audio = audio + msgs = [ + ("stream_options", {"audio_enabled": audio, "video_enabled": True}), + ("camera_options", {"stealth_mode": not audio}), + ] + if not audio: + # Disabling: release the device speaker path first, then stop the + # RTP. The device honours stream_options reliably mid-session but + # stealth_mode only sometimes; sending it first and once more after + # a short delay raises the odds. Known limitation: occasionally the + # handset only gets its audio back when the session ends. + msgs.reverse() + for method, body in msgs: + await ws.send(json.dumps(stream.get_session_message(method, body))) + if not audio: + await asyncio.sleep(1) + if stream.is_alive and stream.websocket is not None: + await ws.send(json.dumps( + stream.get_session_message("camera_options", {"stealth_mode": True}) + )) + _LOGGER.debug("session %s: audio switched %s", stream.session_id, "on" if audio else "off") + return True diff --git a/custom_components/ring_intercom_camera/switch.py b/custom_components/ring_intercom_camera/switch.py new file mode 100644 index 0000000..b8cbedf --- /dev/null +++ b/custom_components/ring_intercom_camera/switch.py @@ -0,0 +1,111 @@ +"""Switch deciding whether live views take the intercom audio. + +On (default): current behaviour — a live view (companion card, browser) gets +the visitor's audio, and the physical handset's speaker goes silent while +the session is open. + +Off: live views are video-only. The handset keeps hearing the visitor, so +the picture can be shown on a TV or wallpanel while the conversation happens +on the handset. Toggling while a session is open applies immediately: Ring +accepts stream_options / camera_options on a running session. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .audio import AudioState, apply_audio_live, get_audio_state + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the audio switches.""" + ring_entries = hass.config_entries.async_entries("ring") + if not ring_entries: + return + + entities = [] + for entry in ring_entries: + ring_data = getattr(entry, "runtime_data", None) + if ring_data is None: + continue + + try: + for device in ring_data.devices.other: + if device.kind == "intercom_handset_video": + entities.append( + RingIntercomAudioSwitch( + device, get_audio_state(device.device_api_id) + ) + ) + except Exception: + _LOGGER.exception("Error discovering Ring Intercom devices") + + if entities: + async_add_entities(entities) + + +class RingIntercomAudioSwitch(SwitchEntity, RestoreEntity): + """Whether live-view sessions may take the intercom audio.""" + + _attr_should_poll = False + _attr_icon = "mdi:volume-high" + + def __init__(self, device, audio: AudioState) -> None: + """Initialize the switch.""" + self._device = device + self._audio = audio + self._attr_name = f"{device.name} Audio" + self._attr_unique_id = f"ring_intercom_audio_{device.device_api_id}" + + async def async_added_to_hass(self) -> None: + """Restore the last state and subscribe to changes.""" + last = await self.async_get_last_state() + if last is not None and last.state in ("on", "off"): + self._audio.set(last.state == "on") + self.async_on_remove(self._audio.add_listener(self._handle_change)) + + @callback + def _handle_change(self) -> None: + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + return self._audio.enabled + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return {"device_id": self._device.device_api_id} + + async def async_turn_on(self, **kwargs: Any) -> None: + await self._set(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self._set(False) + + async def _set(self, enabled: bool) -> None: + self._audio.set(enabled) + # push to running sessions (registered by the patched RingOther) + streams = getattr(self._device, "_webrtc_streams", {}) + for session_id, stream in list(streams.items()): + try: + if await apply_audio_live(stream, enabled): + _LOGGER.info( + "%s: audio %s on running session %s", + self._device.name, "enabled" if enabled else "disabled", session_id, + ) + except Exception: + _LOGGER.exception("Failed to switch audio on session %s", session_id)