From 1b06b2883144f4bdee9af24919b95a91dfc4768b Mon Sep 17 00:00:00 2001 From: Ingo Weinzierl Date: Fri, 6 Mar 2026 14:38:59 +0100 Subject: [PATCH 1/2] Add support for Ring Doorbell Pro 3rd Gen and fix KeyError in FCM event parsing - Add cocoa_doorbell_v5 to DOORBELL_PRO_3_KINDS in const.py - Map model name 'Doorbell Pro (3rd Gen)' and capabilities in doorbot.py - Fix KeyError: 'id' in _get_ring_event when ding object has no id field (newer Ring firmware omits ding.id; use eventito.timestamp as fallback) - Add fixture files and tests for the no-ding-id FCM payload format --- ring_doorbell/const.py | 1 + ring_doorbell/doorbot.py | 7 ++- ring_doorbell/listen/eventlistener.py | 51 ++++++++++++++++--- tests/conftest.py | 26 ++++++++++ .../listen/doorbell_motion_analytics.json | 9 ++++ .../doorbell_motion_android_config.json | 6 +++ .../fixtures/listen/doorbell_motion_data.json | 31 +++++++++++ tests/fixtures/ring_devices.json | 50 ++++++++++++++++++ tests/test_listen.py | 43 +++++++++++++++- tests/test_ring.py | 18 ++++++- 10 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/listen/doorbell_motion_analytics.json create mode 100644 tests/fixtures/listen/doorbell_motion_android_config.json create mode 100644 tests/fixtures/listen/doorbell_motion_data.json diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index 79c88ff..36dfbd5 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -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"] diff --git a/ring_doorbell/doorbot.py b/ring_doorbell/doorbot.py index 7fa6809..7081627 100644 --- a/ring_doorbell/doorbot.py +++ b/ring_doorbell/doorbot.py @@ -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, @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/ring_doorbell/listen/eventlistener.py b/ring_doorbell/listen/eventlistener.py index 660b91c..289da30 100644 --- a/ring_doorbell/listen/eventlistener.py +++ b/ring_doorbell/listen/eventlistener.py @@ -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) @@ -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 ( @@ -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"], @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index 0283a2a..e97dd2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/fixtures/listen/doorbell_motion_analytics.json b/tests/fixtures/listen/doorbell_motion_analytics.json new file mode 100644 index 0000000..b18c540 --- /dev/null +++ b/tests/fixtures/listen/doorbell_motion_analytics.json @@ -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" +} diff --git a/tests/fixtures/listen/doorbell_motion_android_config.json b/tests/fixtures/listen/doorbell_motion_android_config.json new file mode 100644 index 0000000..a74722f --- /dev/null +++ b/tests/fixtures/listen/doorbell_motion_android_config.json @@ -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" +} diff --git a/tests/fixtures/listen/doorbell_motion_data.json b/tests/fixtures/listen/doorbell_motion_data.json new file mode 100644 index 0000000..52aed77 --- /dev/null +++ b/tests/fixtures/listen/doorbell_motion_data.json @@ -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" + } +} diff --git a/tests/fixtures/ring_devices.json b/tests/fixtures/ring_devices.json index 84233fb..567a7cd 100644 --- a/tests/fixtures/ring_devices.json +++ b/tests/fixtures/ring_devices.json @@ -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": [ { diff --git a/tests/test_listen.py b/tests/test_listen.py index 2b3c54b..13683d7 100644 --- a/tests/test_listen.py +++ b/tests/test_listen.py @@ -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): @@ -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) diff --git a/tests/test_ring.py b/tests/test_ring.py index def8f47..bfc3e52 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -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 @@ -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] From ea263e1913a314af7c6d71fa46d63186be964dc3 Mon Sep 17 00:00:00 2001 From: Ingo Weinzierl Date: Fri, 6 Mar 2026 14:52:57 +0100 Subject: [PATCH 2/2] Fixed broken tests. --- tests/test_cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8401223..fba538a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" ) @@ -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