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
1 change: 1 addition & 0 deletions ring_doorbell/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def from_name(name: str) -> RingCapability:
DOORBELL_3_PLUS_KINDS = ["doorbell_scallop"]
DOORBELL_PRO_KINDS = ["lpd_v1", "lpd_v2", "lpd_v3"]
DOORBELL_PRO_2_KINDS = ["lpd_v4"]
DOORBELL_PRO_3_KINDS = ["cocoa_doorbell_v5"]
DOORBELL_ELITE_KINDS = ["jbox_v1"]
DOORBELL_WIRED_KINDS = ["doorbell_graham_cracker"]
DOORBELL_BATTERY_KINDS = ["df_doorbell_clownfish"]
Expand Down
7 changes: 6 additions & 1 deletion ring_doorbell/doorbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
DOORBELL_GEN2_KINDS,
DOORBELL_KINDS,
DOORBELL_PRO_2_KINDS,
DOORBELL_PRO_3_KINDS,
DOORBELL_PRO_KINDS,
DOORBELL_VOL_MAX,
DOORBELL_VOL_MIN,
Expand Down Expand Up @@ -66,6 +67,7 @@ def __init__(self, ring: Ring, device_api_id: int, *, shared: bool = False) -> N
super().__init__(ring, device_api_id)
self.shared = shared
self._webrtc_streams: dict[str, RingWebRtcStream] = {}
_LOGGER.info("Initialised doorbell %s with id %s", self.name, self.device_api_id)

@property
def family(self) -> str:
Expand Down Expand Up @@ -96,6 +98,8 @@ def model(self) -> str: # noqa: C901, PLR0911
return "Doorbell Pro"
if self.kind in DOORBELL_PRO_2_KINDS:
return "Doorbell Pro 2"
if self.kind in DOORBELL_PRO_3_KINDS:
return "Doorbell Pro (3rd Gen)"
if self.kind in DOORBELL_ELITE_KINDS:
return "Doorbell Elite"
if self.kind in DOORBELL_WIRED_KINDS:
Expand Down Expand Up @@ -129,7 +133,7 @@ def has_capability(self, capability: RingCapability | str) -> bool: # noqa: PLR
if capability == RingCapability.KNOCK:
return self.kind in PEEPHOLE_CAM_KINDS
if capability == RingCapability.PRE_ROLL:
return self.kind in DOORBELL_3_PLUS_KINDS
return self.kind in DOORBELL_3_PLUS_KINDS + DOORBELL_PRO_3_KINDS
if capability == RingCapability.VOLUME:
return True
if capability == RingCapability.HISTORY:
Expand All @@ -147,6 +151,7 @@ def has_capability(self, capability: RingCapability | str) -> bool: # noqa: PLR
+ DOORBELL_4_KINDS
+ DOORBELL_PRO_KINDS
+ DOORBELL_PRO_2_KINDS
+ DOORBELL_PRO_3_KINDS
+ DOORBELL_WIRED_KINDS
+ DOORBELL_BATTERY_KINDS
+ DOORBELL_GEN2_KINDS
Expand Down
51 changes: 45 additions & 6 deletions ring_doorbell/listen/eventlistener.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,11 @@ def _on_notification(
obj: Any | None = None, # noqa: ARG002
) -> None:
msg_data = notification["data"]
_logger.debug(
"FCM notification received. Format: %s. Raw data:\n%s",
"legacy (gcmData)" if "gcmData" in msg_data else "modern",
json.dumps(msg_data, indent=2),
)
if "gcmData" in msg_data:
gcm_data = json.loads(notification["data"]["gcmData"])
ring_event = self._get_legacy_ring_event(gcm_data)
Expand All @@ -298,11 +303,19 @@ def _on_notification(

if ring_event:
self._check_is_update(ring_event)
_logger.debug("Event received %s", ring_event)
_logger.debug(
"FCM event parsed successfully: id=%s doorbot_id=%s device_kind=%s kind=%s state=%s is_update=%s",
ring_event.id,
ring_event.doorbot_id,
ring_event.device_kind,
ring_event.kind,
ring_event.state,
ring_event.is_update,
)
for callback in self._callbacks.values():
callback(ring_event)
else:
_logger.debug("Unknown event received %s", msg_data)
_logger.debug("FCM event could not be parsed. Raw msg_data:\n%s", json.dumps(msg_data, indent=2))

def _get_ring_event(self, msg_data: dict) -> RingEvent | None:
if (android_config_str := msg_data.get("android_config")) is None or (
Expand All @@ -318,11 +331,32 @@ def _get_ring_event(self, msg_data: dict) -> RingEvent | None:
data = json.loads(data_str)
event_category = android_config["category"]
event_kind = PUSH_NOTIFICATION_KINDS.get(event_category, "Unknown")
_logger.debug(
"FCM modern event: category=%s -> kind=%s (known=%s)",
event_category,
event_kind,
event_category in PUSH_NOTIFICATION_KINDS,
)
device = data["device"]
event = data["event"]
event_id = int(event["ding"]["id"])
created_at = event["ding"]["created_at"]
create_seconds = parse_datetime(created_at).timestamp()
ding = event.get("ding", {})
_logger.debug(
"FCM modern event ding payload: keys=%s full_event_keys=%s",
list(ding.keys()),
list(event.keys()),
)
# Newer device firmware (e.g. cocoa_doorbell_v5) omits "id" from the
# ding object. Fall back to the millisecond timestamp from "eventito"
# which is unique per event, or use the current time as a last resort.
eventito = event.get("eventito", {})
if "id" in ding:
event_id = int(ding["id"])
elif "timestamp" in eventito:
event_id = int(eventito["timestamp"])
else:
event_id = int(time.time() * 1000)
created_at = ding.get("created_at")
create_seconds = parse_datetime(created_at).timestamp() if created_at else time.time()
return RingEvent(
event_id,
device["id"],
Expand All @@ -331,11 +365,16 @@ def _get_ring_event(self, msg_data: dict) -> RingEvent | None:
kind=event_kind,
now=create_seconds,
expires_in=DEFAULT_LISTEN_EVENT_EXPIRES_IN,
state=event["ding"]["subtype"],
state=ding.get("subtype", ""),
)

def _get_legacy_ring_event(self, gcm_data: dict) -> RingEvent | None:
re: RingEvent | None = None
_logger.debug(
"FCM legacy event: action=%s keys=%s",
gcm_data.get("action"),
list(gcm_data.keys()),
)
if "ding" in gcm_data:
re = self._get_ding_event(gcm_data)
elif gcm_data.get("action") == PUSH_ACTION_INTERCOM_UNLOCK:
Expand Down
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,32 @@ def load_alert_v2(
return msg


def load_alert_v2_no_ding_id(
alert_type: str, device_id, *, eventito_timestamp_inc: int = 0, created_at: str | None = None
) -> dict:
"""Load a v2 alert where the ding object has no 'id' field (newer device firmware)."""
msg = json.loads(load_fixture(Path().joinpath("listen", "fcmdata_v2.json")))
data = json.loads(
load_fixture(Path().joinpath("listen", f"{alert_type}_data.json"))
)
android_config = json.loads(
load_fixture(Path().joinpath("listen", f"{alert_type}_android_config.json"))
)
analytics = json.loads(
load_fixture(Path().joinpath("listen", f"{alert_type}_analytics.json"))
)
if created_at is None:
created_at = datetime.datetime.utcnow().isoformat(timespec="milliseconds") + "Z" # noqa: DTZ003
data["device"]["id"] = device_id
data["event"]["ding"]["created_at"] = created_at
data["event"]["ding"].pop("id", None) # Remove id to simulate newer firmware
data["event"]["eventito"]["timestamp"] = data["event"]["eventito"]["timestamp"] + eventito_timestamp_inc
msg["data"]["data"] = json.dumps(data)
msg["data"]["android_config"] = json.dumps(android_config)
msg["data"]["analytics"] = json.dumps(analytics)
return msg


@pytest.fixture(autouse=True)
def _listen_mock(mocker, request) -> None:
if "nolistenmock" in request.keywords:
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/listen/doorbell_motion_analytics.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"server_correlation_id": "123456789|human|1772800029955_Scc339d31",
"server_id": "com.ring.pns",
"subcategory": "human",
"triggered_at": 1772800029955,
"sent_at": 1772800030397,
"referring_item_type": "device_id",
"referring_item_id": "123456789"
}
6 changes: 6 additions & 0 deletions tests/fixtures/listen/doorbell_motion_android_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"group_key": "7d79c555-1d4f-49cd-99b7-ffc08528fb2a",
"category": "com.ring.pn.live-event.motion",
"channel": "motion_channel_notification123456789",
"body": "There is a Person at your Front Door"
}
31 changes: 31 additions & 0 deletions tests/fixtures/listen/doorbell_motion_data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"device": {
"e2ee_enabled": false,
"id": 123456789,
"kind": "cocoa_doorbell_v5",
"name": "Front Door"
},
"event": {
"ding": {
"created_at": "2026-03-06T12:27:09Z",
"subtype": "human",
"detection_type": "human"
},
"eventito": {
"initial_motion_offset": 4,
"type": "human",
"timestamp": 1772800029955
},
"riid": "b52155a7be5ffb69cf9b66f3d2486950",
"is_sidewalk": false,
"description_provider": "default",
"live_session": {
"active_streaming_profile": "rms",
"default_audio_route": "loud_speaker",
"max_duration": 120
}
},
"location": {
"id": "7d79c555-1d4f-49cd-99b7-ffc08528fb2a"
}
}
50 changes: 50 additions & 0 deletions tests/fixtures/ring_devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,56 @@
"high"]},
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"},
{
"address": "456 Main St",
"alerts": {"connection": "online"},
"battery_life": null,
"description": "Front Door Pro 3",
"device_id": "aacdef125",
"external_connection": false,
"features": {
"advanced_motion_enabled": false,
"motion_message_enabled": false,
"motions_enabled": true,
"people_only_enabled": false,
"shadow_correction_enabled": false,
"show_recordings": true},
"firmware_version": "2.0.0",
"id": 987654,
"kind": "cocoa_doorbell_v5",
"latitude": 12.000000,
"longitude": -70.12345,
"motion_snooze": null,
"owned": true,
"owner": {
"email": "foo@bar.org",
"first_name": "Home",
"id": 999999,
"last_name": "Assistant"},
"settings": {
"chime_settings": {
"duration": 3,
"enable": true,
"type": 0},
"doorbell_volume": 3,
"enable_vod": true,
"live_view_preset_profile": "highest",
"live_view_presets": [
"low",
"middle",
"high",
"highest"],
"motion_detection_enabled": true,
"motion_announcement": false,
"motion_snooze_preset_profile": "low",
"motion_snooze_presets": [
"none",
"low",
"medium",
"high"]},
"subscribed": true,
"subscribed_motions": true,
"time_zone": "America/New_York"}],
"stickup_cams": [
{
Expand Down
5 changes: 3 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ async def test_list(ring):
res = await runner.invoke(list_command, obj=ring)

expected = (
"Front Door (lpd_v1)\nBack Door (lpd_v1)\nDownstairs (chime)\n"
"Front Door (lpd_v1)\nFront Door Pro 3 (cocoa_doorbell_v5)\n"
"Back Door (lpd_v1)\nDownstairs (chime)\n"
"Front (hp_cam_v1)\nIngress (intercom_handset_audio)\n"
)

Expand Down Expand Up @@ -340,7 +341,7 @@ async def test_in_home_chime(ring, aioresponses_mock, devices_fixture):
[],
obj=ring,
)
expected = "There are 2 doorbells, you need to pass the --device-name option"
expected = "There are 3 doorbells, you need to pass the --device-name option"
assert res.exit_code == 1
assert expected in res.output

Expand Down
43 changes: 42 additions & 1 deletion tests/test_listen.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from ring_doorbell.exceptions import RingError
from ring_doorbell.listen import RingEventListener

from tests.conftest import load_alert_v1, load_alert_v2, load_fixture
from tests.conftest import load_alert_v1, load_alert_v2, load_alert_v2_no_ding_id, load_fixture


async def test_listen(auth, mocker):
Expand Down Expand Up @@ -74,6 +74,47 @@ async def test_active_dings(auth, mocker):
await listener.stop()


async def test_active_dings_no_ding_id(auth, mocker):
"""Test that events from devices without ding.id (e.g. cocoa_doorbell_v5) are handled."""
ring = Ring(auth)
listener = RingEventListener(ring)
await listener.start()
assert listener.subscribed is True
assert listener.started is True

num_active = len(ring.active_alerts())
assert num_active == 0

base_timestamp = 1772800029955
device_id = 123456789
alertstoadd = 2
for i in range(alertstoadd):
msg = load_alert_v2_no_ding_id(
"doorbell_motion", device_id, eventito_timestamp_inc=i
)
listener._on_notification(msg, "1234567" + str(i))

dings = ring.active_alerts()
assert len(dings) == alertstoadd
assert all(d.kind == "motion" for d in dings)
assert all(d.doorbot_id == device_id for d in dings)
# IDs should be derived from eventito.timestamp since ding.id is absent
ids = {d.id for d in dings}
assert ids == {base_timestamp, base_timestamp + 1}

# Sending the same events again should not increase the count (they are updates)
for i in range(alertstoadd):
msg = load_alert_v2_no_ding_id(
"doorbell_motion", device_id, eventito_timestamp_inc=i
)
listener._on_notification(msg, "1234567" + str(i))

dings = ring.active_alerts()
assert len(dings) == alertstoadd

await listener.stop()


async def test_ding_expirey(auth, mocker, freezer: FrozenDateTimeFactory):
ring = Ring(auth)
listener = RingEventListener(ring)
Expand Down
18 changes: 17 additions & 1 deletion tests/test_ring.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def test_basic_attributes(ring):
"""Test the Ring class and methods."""
data = ring.devices()
assert len(data["chimes"]) == 1
assert len(data["doorbots"]) == 1
assert len(data["doorbots"]) == 2
assert len(data["authorized_doorbots"]) == 1
assert len(data["stickup_cams"]) == 1
assert len(data["other"]) == 1
Expand Down Expand Up @@ -84,6 +84,22 @@ async def test_doorbell_attributes(ring):
assert dev.wifi_signal_strength == -58


def test_doorbell_pro3_attributes(ring):
"""Test the Ring Doorbell Pro 3rd Gen model name and capabilities."""
dev = ring.devices()["doorbots"][1]
assert dev.name == "Front Door Pro 3"
assert dev.id == 987654
assert dev.kind == "cocoa_doorbell_v5"
assert dev.model == "Doorbell Pro (3rd Gen)"
assert dev.has_capability("battery") is False
assert dev.has_capability("volume") is True
assert dev.has_capability("history") is True
assert dev.has_capability("motion_detection") is True
assert dev.has_capability("video") is True
assert dev.has_capability("ding") is True
assert dev.has_capability("pre_roll") is True


def test_shared_doorbell_attributes(ring):
data = ring.devices()
dev = data["authorized_doorbots"][0]
Expand Down