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
93 changes: 93 additions & 0 deletions ring_doorbell/other.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@

from __future__ import annotations

import asyncio
import json
import logging
import time
import uuid
import aiofiles
from typing import TYPE_CHECKING, Any, ClassVar

from ring_doorbell.const import (
SNAPSHOT_ENDPOINT,
SNAPSHOT_TIMESTAMP_ENDPOINT,
DOORBELLS_ENDPOINT,
HEALTH_DOORBELL_ENDPOINT,
INTERCOM_ALLOWED_USERS,
Expand All @@ -28,6 +33,10 @@
)
from ring_doorbell.exceptions import RingError
from ring_doorbell.generic import RingGeneric
from ring_doorbell.webrtcstream import (
RingWebRtcMessageCallback,
RingWebRtcStream,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -42,6 +51,7 @@ def __init__(self, ring: Ring, device_api_id: int, *, shared: bool = False) -> N
"""Initialise the other devices."""
super().__init__(ring, device_api_id)
self.shared = shared
self._webrtc_streams: dict[str, RingWebRtcStream] = {}

@property
def family(self) -> str:
Expand Down Expand Up @@ -75,6 +85,10 @@ def has_capability(self, capability: RingCapability | str) -> bool:
RingCapability.DING,
]:
return self.kind in INTERCOM_KINDS

if capability == RingCapability.VIDEO:
return self.kind == "intercom_handset_video"

return False

@property
Expand Down Expand Up @@ -106,6 +120,39 @@ def has_subscription(self) -> bool:
return features.get("show_recordings", False)
return False

async def async_get_snapshot(
self, retries: int = 3, delay: int = 1, filename: str | None = None
) -> bytes | None:
"""Take a snapshot and download it."""
payload = {"doorbot_ids": [self._attrs.get("id")]}
await self._ring.async_query(
SNAPSHOT_TIMESTAMP_ENDPOINT, method="POST", json=payload
)
request_time = time.time()
for _ in range(retries):
await asyncio.sleep(delay)
resp = await self._ring.async_query(
SNAPSHOT_TIMESTAMP_ENDPOINT, method="POST", json=payload
)
response = resp.json()
timestamps = response.get("timestamps") or []
if not timestamps:
continue

timestamp = timestamps[0].get("timestamp")
if timestamp is not None and timestamp / 1000 > request_time:
resp = await self._ring.async_query(
SNAPSHOT_ENDPOINT.format(self._attrs.get("id"))
)
snapshot = resp.content
if filename:
async with aiofiles.open(filename, "wb") as jpg:
await jpg.write(snapshot)
return None
return snapshot

return None

@property
def unlock_duration(self) -> str | None:
"""Return time unlock switch is held closed."""
Expand Down Expand Up @@ -271,6 +318,52 @@ async def async_remove_access(self, user_id: int) -> bool:

return False

async def generate_async_webrtc_stream(
self,
sdp_offer: str,
session_id: str,
on_message_callback: RingWebRtcMessageCallback,
*,
keep_alive_timeout: int | None = 60 * 5,
) -> None:
"""Generate the rtc stream."""

async def _close_callback() -> None:
await self.close_webrtc_stream(session_id)

stream = RingWebRtcStream(
self._ring,
self.device_api_id,
on_message_callback=on_message_callback,
keep_alive_timeout=keep_alive_timeout,
on_close_callback=_close_callback,
)
self._webrtc_streams[session_id] = stream
await stream.generate(sdp_offer)

async def on_webrtc_candidate(
self, session_id: str, candidate: str, multi_line_index: int
) -> None:
"""Send an ICE candidate."""
if stream := self._webrtc_streams.get(session_id):
await stream.on_ice_candidate(candidate, multi_line_index)
else:
msg = "Ice candidate received before stream has been created."
raise RingError(msg)

async def close_webrtc_stream(self, session_id: str) -> None:
"""Close the rtc stream."""
stream = self._webrtc_streams.pop(session_id, None)
if stream:
await stream.close()

def sync_close_webrtc_stream(self, session_id: str) -> None:
"""Close the rtc stream."""
stream = self._webrtc_streams.pop(session_id, None)
if stream:
stream.sync_close()


DEPRECATED_API_QUERIES: ClassVar = {
*RingGeneric.DEPRECATED_API_QUERIES,
"update_health_data",
Expand Down
26 changes: 20 additions & 6 deletions ring_doorbell/ring.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,20 @@ def get_device_by_api_id(self, device_api_id: int) -> RingGeneric | None:
else all_devices[api_id_to_idx[device_api_id]]
)

def video_devices(self) -> Sequence[RingDoorBell]:
"""Get all devices."""
def video_devices(self) -> Sequence[RingDoorBell | RingOther]:
"""Get all video-capable devices."""
devices = self.devices()
return list(
chain(devices.doorbots, devices.authorized_doorbots, devices.stickup_cams)
chain(
devices.doorbots,
devices.authorized_doorbots,
devices.stickup_cams,
(
device
for device in devices.other
if device.has_capability("video")
),
)
)

def groups(self) -> Mapping[str, RingLightGroup]:
Expand Down Expand Up @@ -407,9 +416,14 @@ def all_devices(self) -> Sequence[RingGeneric]:
return list(self._all_devices.values())

@property
def video_devices(self) -> Sequence[RingDoorBell]:
"""The video devices, i.e. doorbells and stickup_cams."""
return [*self._doorbots, *self._authorized_doorbots, *self._stickup_cams]
def video_devices(self) -> Sequence[RingDoorBell | RingOther]:
"""The video devices, i.e. doorbells, stickup cams, and video intercoms."""
return [
*self._doorbots,
*self._authorized_doorbots,
*self._stickup_cams,
*(device for device in self._other if device.has_capability("video")),
]

def get_device(self, device_api_id: int) -> RingGeneric:
"""Get device by api id."""
Expand Down
11 changes: 10 additions & 1 deletion ring_doorbell/webrtcstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,8 @@ async def _close(self, *, closed_by_self: bool) -> None:
await websocket.close()
if read_task := self.read_task:
self.read_task = None
if read_task is asyncio.current_task():
return
if not read_task.done():
await read_task

Expand All @@ -413,7 +415,14 @@ async def handle_message(self, message_str: str) -> None: # noqa: C901, PLR0912
else:
_LOGGER.debug("Received notification: %s", message)
elif method == "session_created":
self.session_id = message["body"]["session_id"]
body = message.get("body") or {}
if not (session_id := body.get("session_id")):
_LOGGER.warning(
"Ignoring session_created without session_id (body keys: %s)",
sorted(body),
)
return
self.session_id = session_id
if TYPE_CHECKING:
assert self.session_id
_LOGGER.debug(
Expand Down
30 changes: 30 additions & 0 deletions tests/test_other.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""The tests for the Ring platform."""

from unittest.mock import AsyncMock, MagicMock

from .conftest import json_request_kwargs, nojson_request_kwargs


Expand Down Expand Up @@ -142,3 +144,31 @@ async def test_other_open_door(ring, aioresponses_mock, mocker):
method="PUT",
**kwargs,
)


async def test_intercom_video_webrtc_support(ring):
"""Test WebRTC methods and video capability for a video intercom."""
dev = ring.devices()["other"][0]
dev._attrs["kind"] = "intercom_handset_video"

assert dev.has_capability("video") is True
assert hasattr(dev, "generate_async_webrtc_stream")
assert hasattr(dev, "on_webrtc_candidate")
assert hasattr(dev, "close_webrtc_stream")
assert hasattr(dev, "sync_close_webrtc_stream")


async def test_intercom_snapshot_handles_empty_timestamps(ring, mocker):
"""Test an empty Ring timestamp response does not raise an exception."""
dev = ring.devices()["other"][0]
empty_response = MagicMock()
empty_response.json.return_value = {"timestamps": []}
query = mocker.patch.object(
dev._ring,
"async_query",
new=AsyncMock(return_value=empty_response),
)
mocker.patch("ring_doorbell.other.asyncio.sleep", new=AsyncMock())

assert await dev.async_get_snapshot(retries=2, delay=0) is None
assert query.await_count == 3
27 changes: 27 additions & 0 deletions tests/test_webrtcstream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Tests for the Ring WebRTC stream handler."""

import asyncio
from unittest.mock import MagicMock

from ring_doorbell.webrtcstream import RingWebRtcStream


async def test_session_created_without_session_id_is_ignored(caplog):
"""Test malformed session-created messages do not break the stream."""
stream = RingWebRtcStream(MagicMock(), 123)

await stream.handle_message('{"method":"session_created","body":{}}')

assert stream.session_id is None
assert "without session_id" in caplog.text


async def test_close_from_reader_does_not_await_itself():
"""Test closing from the reader task does not await the same task."""
stream = RingWebRtcStream(MagicMock(), 123)
stream.read_task = asyncio.current_task()

await stream._close(closed_by_self=False)

assert stream.read_task is None
assert stream.is_alive is False