Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<device>_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 <id>: audio=False` for such sessions.

---

## 🧪 Technical details
Expand Down
36 changes: 32 additions & 4 deletions custom_components/ring_intercom_camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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"):
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
149 changes: 149 additions & 0 deletions custom_components/ring_intercom_camera/audio.py
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions custom_components/ring_intercom_camera/switch.py
Original file line number Diff line number Diff line change
@@ -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)