Skip to content
Merged
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
134 changes: 69 additions & 65 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18
sphinx = {version = "<7.2.6", optional = true}
sphinx-rtd-theme = {version = "^1.3.0", optional = true}
myst-parser = { version = "*", optional = true }
firebase-messaging = {version = "^0.3.0", optional = true}
firebase-messaging = {version = "^0.4.0", optional = true}
typing-extensions = ">=4.12.2,<5.0"
async-timeout = ">=3.0.0"

[tool.poetry.group.dev.dependencies]
mock = "*"
Expand Down
4 changes: 2 additions & 2 deletions ring_doorbell/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,12 +756,12 @@ def credentials_updated_callback(credentials) -> None:
credentials = json.load(f)

event_listener = RingEventListener(ring, credentials, credentials_updated_callback)
await event_listener.async_start()
await event_listener.start()
event_listener.add_notification_callback(_event_handler(ring).on_event)

await ainput("Listening, press enter to cancel\n")

event_listener.stop()
await event_listener.stop()


if __name__ == "__main__":
Expand Down
24 changes: 23 additions & 1 deletion ring_doorbell/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class RingCapability(Enum):
OPEN = auto()
KNOCK = auto()
PRE_ROLL = auto()
DING = auto()

@staticmethod
def from_name(name: str) -> RingCapability:
Expand Down Expand Up @@ -72,7 +73,10 @@ def from_name(name: str) -> RingCapability:

DEFAULT_LISTEN_EVENT_EXPIRES_IN = 180
# for Ring android app. 703521446232 for ring-site
RING_SENDER_ID = 876313859327
FCM_RING_SENDER_ID = "876313859327"
FCM_API_KEY = "AIzaSyCv-hdFBmmdBBJadNy-TFwB-xN_H5m3Bk8"
FCM_PROJECT_ID = "ring-17770"
FCM_APP_ID = "1:876313859327:android:e10ec6ddb3c81f39"

CLI_TOKEN_FILE = "ring_token.cache" # noqa: S105
GCM_TOKEN_FILE = "ring_gcm_token.cache" # noqa: S105
Expand Down Expand Up @@ -112,6 +116,11 @@ def from_name(name: str) -> RingCapability:
KIND_DING = "ding"
KIND_MOTION = "motion"
KIND_INTERCOM_UNLOCK = "intercom_unlock"
KIND_ALARM_MODE_NONE = "alarm_mode_none"
KIND_ALARM_MODE_SOME = "alarm_mode_some"
KIND_ALARM_SIREN = "alarm_siren"
KIND_ALARM_SILENCED = "alarm_silenced"

# chime test sound kinds
CHIME_TEST_SOUND_KINDS = (KIND_DING, KIND_MOTION)

Expand Down Expand Up @@ -183,6 +192,19 @@ def from_name(name: str) -> RingCapability:
PUSH_ACTION_MOTION = "com.ring.push.HANDLE_NEW_motion"
PUSH_ACTION_INTERCOM_UNLOCK = "com.ring.push.INTERCOM_UNLOCK_FROM_APP"

PUSH_NOTIFICATION_KINDS = {
PUSH_ACTION_DING: KIND_DING, # legacy
"com.ring.pn.live-event.ding": KIND_DING,
PUSH_ACTION_MOTION: KIND_MOTION, # legacy
"com.ring.pn.live-event.motion": KIND_MOTION,
"com.ring.pn.live-event.intercom": KIND_DING,
PUSH_ACTION_INTERCOM_UNLOCK: KIND_INTERCOM_UNLOCK,
"com.ring.push.HANDLE_NEW_SECURITY_PANEL_MODE_NONE_NOTICE": KIND_ALARM_MODE_NONE,
"com.ring.push.HANDLE_NEW_SECURITY_PANEL_MODE_SOME_NOTICE": KIND_ALARM_MODE_SOME,
"com.ring.push.HANDLE_NEW_USER_SOUND_SIREN": KIND_ALARM_SIREN,
"com.ring.push.HANDLE_NEW_NON_ALARM_SIREN_SILENCED": KIND_ALARM_SILENCED,
}

POST_DATA_JSON = {
"api_version": API_VERSION,
"device_model": "ring-doorbell",
Expand Down
6 changes: 5 additions & 1 deletion ring_doorbell/doorbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ def has_capability(self, capability: RingCapability | str) -> bool: # noqa: PLR
return True
if capability == RingCapability.HISTORY:
return True
if capability in [RingCapability.MOTION_DETECTION, RingCapability.VIDEO]:
if capability in [
RingCapability.MOTION_DETECTION,
RingCapability.VIDEO,
RingCapability.DING,
]:
return self.kind in (
DOORBELL_KINDS
+ DOORBELL_2_KINDS
Expand Down
157 changes: 110 additions & 47 deletions ring_doorbell/listen/eventlistener.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@
import time
from typing import TYPE_CHECKING, Any, Callable, ClassVar

from firebase_messaging import FcmPushClient
from async_timeout import timeout as asyncio_timeout
from firebase_messaging import FcmPushClient, FcmRegisterConfig

from ring_doorbell.const import (
API_URI,
API_VERSION,
DEFAULT_LISTEN_EVENT_EXPIRES_IN,
FCM_API_KEY,
FCM_APP_ID,
FCM_PROJECT_ID,
FCM_RING_SENDER_ID,
KIND_DING,
KIND_INTERCOM_UNLOCK,
KIND_MOTION,
PUSH_ACTION_DING,
PUSH_ACTION_INTERCOM_UNLOCK,
PUSH_ACTION_MOTION,
RING_SENDER_ID,
PUSH_NOTIFICATION_KINDS,
SUBSCRIPTION_ENDPOINT,
)
from ring_doorbell.event import RingEvent
Expand All @@ -41,6 +46,8 @@
class RingEventListener:
"""Class to connect to firebase cloud messaging."""

SESSION_REFRESH_INTERVAL = 60 * 60 * 12

def __init__(
self,
ring: Ring,
Expand Down Expand Up @@ -72,6 +79,14 @@ def __init__(
self._subscription_counter = 1
self._intercom_unlock_counter: dict[int, int] = {}

self.session_refresh_task: asyncio.Task | None = None
self.fcm_token: str | None = None

def _credentials_updated_cb(self, creds: dict[str, Any]) -> None:
self._credentials = creds
if self._credentials_updated_callback:
self._credentials_updated_callback(creds)

async def async_add_subscription_to_ring(self, token: str) -> None:
"""Add subscription to ring."""
if not self._ring.session:
Expand All @@ -82,6 +97,7 @@ async def async_add_subscription_to_ring(self, token: str) -> None:
"metadata": {
"api_version": API_VERSION,
"device_model": self._device_model,
"pn_dict_version": "2.0.0",
"pn_service": "fcm",
},
"os": "android",
Expand All @@ -108,8 +124,6 @@ async def async_add_subscription_to_ring(self, token: str) -> None:
# Update devices for the intercom unlock events
if not self._ring.devices_data:
await self._ring.async_update_devices()
if not self._ring.dings_data:
await self._ring.async_update_dings()

def add_notification_callback(self, callback: OnNotificationCallable) -> int:
"""Add a callback to be notified on event."""
Expand All @@ -132,63 +146,74 @@ def remove_notification_callback(self, subscription_id: int) -> None:

del self._callbacks[subscription_id]

if len(self._callbacks) == 0 and self._receiver:
self._receiver.stop()
self._receiver = None

def stop(self) -> None:
async def stop(self) -> None:
"""Stop the listener."""
self.started = False

if self._receiver:
self.started = False
self._receiver.stop()
self._receiver = None
await self._receiver.stop()

refresh_task = self.session_refresh_task
self.session_refresh_task = None
if refresh_task and not refresh_task.done():
refresh_task.cancel()

self._callbacks = {}

async def async_start(
async def start(
self,
callback: OnNotificationCallable | None = None,
*,
listen_loop: asyncio.AbstractEventLoop | None = None,
callback_loop: asyncio.AbstractEventLoop | None = None,
timeout: int = 30,
timeout: int = 10,
) -> bool:
"""Start the listener."""
if not callback:
callback = self._ring._add_event_to_dings_data # noqa: SLF001

if not self._receiver:
fcm_config = FcmRegisterConfig(
FCM_PROJECT_ID, FCM_APP_ID, FCM_API_KEY, FCM_RING_SENDER_ID
)
self._receiver = FcmPushClient(
credentials=self._credentials,
credentials_updated_callback=self._credentials_updated_callback,
self._on_notification,
fcm_config,
self._credentials,
self._credentials_updated_cb,
config=self._config,
http_client_session=self._ring.auth.http_client_session,
http_client_session=self._ring.auth._session, # noqa: SLF001
)
self.fcm_token = await self._receiver.checkin_or_register()
if not self.fcm_token:
_logger.error(
"Ring listener unable to check in to fcm, " "event listener not started"
)
fcm_token: str | None = await self._receiver.checkin(
RING_SENDER_ID, self._app_id
)
if not fcm_token:
_logger.error("Unable to check in to fcm, event listener not started")
return False

await self.async_add_subscription_to_ring(fcm_token)
if not self.subscribed:
await self.async_add_subscription_to_ring(self.fcm_token)
if self.subscribed:
self.add_notification_callback(callback)
self.add_notification_callback(self._ring._add_event_to_dings_data) # noqa: SLF001

self._receiver.start(
self._on_notification,
listen_event_loop=listen_loop,
callback_event_loop=callback_loop,
async with asyncio_timeout(timeout):
await self._receiver.start()
self.started = True
self.session_refresh_task = asyncio.create_task(
self._periodic_session_refresh()
)
return self.started

start = time.time()
now = start
while not self._receiver.is_started() and now - start < timeout:
await asyncio.sleep(0.1)
now = time.time()
self.started = self._receiver.is_started()
async def _periodic_session_refresh(self) -> None:
while self.started:
now = time.monotonic()
if TYPE_CHECKING:
assert self._ring.session_refresh_time
assert self.fcm_token
since_refresh = now - self._ring.session_refresh_time

return self.subscribed and self.started
if since_refresh > self.SESSION_REFRESH_INTERVAL:
_logger.debug("Refreshing ring session")
await self._ring.async_create_session()
await self.async_add_subscription_to_ring(self.fcm_token)
break

sleep_for = 1 + time.monotonic() - self._ring.session_refresh_time
await asyncio.sleep(sleep_for)

def _get_ding_event(self, gcm_data: dict[str, Any]) -> RingEvent:
ding = gcm_data["ding"]
Expand Down Expand Up @@ -243,8 +268,48 @@ def _on_notification(
persistent_id: str, # noqa: ARG002
obj: Any | None = None, # noqa: ARG002
) -> None:
gcm_data = json.loads(notification["data"]["gcmData"])
msg_data = notification["data"]
if "gcmData" in msg_data:
gcm_data = json.loads(notification["data"]["gcmData"])
ring_event = self._get_legacy_ring_event(gcm_data)
else:
ring_event = self._get_ring_event(msg_data)

if ring_event:
for callback in self._callbacks.values():
callback(ring_event)

def _get_ring_event(self, msg_data: dict) -> RingEvent | None:
if (android_config_str := msg_data.get("android_config")) is None or (
data_str := msg_data.get("data")
) is None:
_logger.debug(
"Unexpected alert type in fcm message data. Full message is:\n%s",
json.dumps(msg_data),
)
return None

android_config = json.loads(android_config_str)
data = json.loads(data_str)
event_category = android_config["category"]
event_kind = PUSH_NOTIFICATION_KINDS.get(event_category, "Unknown")
device = data["device"]
event = data["event"]
event_id = event["ding"]["id"]
created_at = event["ding"]["created_at"]
create_seconds = parse_datetime(created_at).timestamp()
return RingEvent(
event_id,
device["id"],
device_name=device.get("name"),
device_kind=device.get("kind"),
kind=event_kind,
now=create_seconds,
expires_in=DEFAULT_LISTEN_EVENT_EXPIRES_IN,
state=event["ding"]["subtype"],
)

def _get_legacy_ring_event(self, gcm_data: dict) -> RingEvent | None:
re: RingEvent | None = None
if "ding" in gcm_data:
re = self._get_ding_event(gcm_data)
Expand All @@ -253,12 +318,10 @@ def _on_notification(
elif "community_alert" not in gcm_data:
_logger.debug(
"Unexpected alert type in gcmData. Full message is:\n%s",
json.dumps(notification),
json.dumps(gcm_data),
)
return
if re:
for callback in self._callbacks.values():
callback(re)
return None
return re

DEPRECATED_API_QUERIES: ClassVar = {
"start",
Expand Down
6 changes: 5 additions & 1 deletion ring_doorbell/other.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ def has_capability(self, capability: RingCapability | str) -> bool:
if isinstance(capability, RingCapability)
else RingCapability.from_name(capability)
)
if capability in [RingCapability.OPEN, RingCapability.HISTORY]:
if capability in [
RingCapability.OPEN,
RingCapability.HISTORY,
RingCapability.DING,
]:
return self.kind in INTERCOM_KINDS
return False

Expand Down
8 changes: 5 additions & 3 deletions ring_doorbell/ring.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from __future__ import annotations

import logging
import time
from itertools import chain
from time import time
from typing import TYPE_CHECKING, Any, ClassVar

from ring_doorbell import RingEvent
Expand Down Expand Up @@ -50,6 +50,7 @@ def __init__(self, auth: Auth) -> None:
self.push_dings_data: list[RingEvent] = []
self.groups_data: dict[str, dict[str, Any]] = {}
self.init_loop = None
self.session_refresh_time: float | None = None

async def async_update_data(self) -> None:
"""Update all data."""
Expand All @@ -67,7 +68,7 @@ async def _async_update_data(self) -> None:

def _add_event_to_dings_data(self, ring_event: RingEvent) -> None:
# Purge expired push_dings
now = time()
now = time.monotonic()
self.push_dings_data = [
re for re in self.push_dings_data if now < re.now + re.expires_in
]
Expand All @@ -91,6 +92,7 @@ async def async_create_session(self) -> None:
json=session_post_data,
)
self.session = resp.json()
self.session_refresh_time = time.monotonic()

async def async_update_devices(self) -> None:
"""Update device data."""
Expand Down Expand Up @@ -244,7 +246,7 @@ def groups(self) -> Mapping[str, RingLightGroup]:

def active_alerts(self) -> Sequence[RingEvent]:
"""Get active alerts."""
now = time()
now = time.time()
# Purge expired push_dings
self.push_dings_data = [
re for re in self.push_dings_data if now < re.now + re.expires_in
Expand Down
Loading