"""Events from pubsub subscriber.""" from __future__ import annotations from abc import ABC, abstractmethod import base64 import binascii from dataclasses import dataclass, field import datetime from enum import StrEnum import hashlib import json import logging import traceback from typing import Any, Iterable, Mapping, ClassVar from mashumaro import DataClassDictMixin, field_options from mashumaro.config import ( BaseConfig, ) from mashumaro.types import SerializationStrategy from .auth import AbstractAuth from .exceptions import DecodeException from .registry import Registry __all__ = [ "EventMessage", "CameraMotionEvent", "CameraPersonEvent", "CameraSoundEvent", "DoorbellChimeEvent", "CameraClipPreviewEvent", "EventImageType", "EventProcessingError", ] EVENT_ID = "eventId" EVENT_SESSION_ID = "eventSessionId" TIMESTAMP = "timestamp" RESOURCE_UPDATE = "resourceUpdate" NAME = "name" TRAITS = "traits" EVENTS = "events" PREVIEW_URL = "previewUrl" ZONES = "zones" EVENT_THREAD_STATE_ENDED = "ENDED" # Event images expire 30 seconds after the event is published EVENT_IMAGE_EXPIRE_SECS = 30 # Camera clip previews don't list an expiration in the API. Lets say 15 minutes # as an arbitrary number for now. CAMERA_CLIP_PREVIEW_EXPIRE_SECS = 15 * 60 EVENT_MAP = Registry() _LOGGER = logging.getLogger(__name__) class EventType(StrEnum): """Types of events.""" CAMERA_MOTION = "sdm.devices.events.CameraMotion.Motion" CAMERA_PERSON = "sdm.devices.events.CameraPerson.Person" CAMERA_SOUND = "sdm.devices.events.CameraSound.Sound" DOORBELL_CHIME = "sdm.devices.events.DoorbellChime.Chime" CAMERA_CLIP_PREVIEW = "sdm.devices.events.CameraClipPreview.ClipPreview" class EventProcessingError(Exception): """Raised when there was an error handling an event.""" @dataclass(frozen=True) class EventImageContentType(DataClassDictMixin): """Event image content type.""" content_type: str def __str__(self) -> str: """Return a string representation of the event image type.""" return self.content_type class EventImageType(ABC): IMAGE = EventImageContentType("image/jpeg") CLIP_PREVIEW = EventImageContentType("video/mp4") IMAGE_PREVIEW = EventImageContentType("image/gif") @staticmethod def from_string(content_type: str) -> EventImageContentType: """Parse an EventImageType from a string representation.""" if content_type == EventImageType.CLIP_PREVIEW.content_type: return EventImageType.CLIP_PREVIEW elif content_type == EventImageType.IMAGE.content_type: return EventImageType.IMAGE elif content_type == EventImageType.IMAGE_PREVIEW.content_type: return EventImageType.IMAGE_PREVIEW else: return EventImageContentType(content_type) @dataclass class EventToken: """Identifier for a unique event.""" event_session_id: str = field(metadata=field_options(alias="eventSessionId")) event_id: str = field(metadata=field_options(alias="eventId")) def encode(self) -> str: """Encode the event token as a serialized string.""" data = [self.event_session_id, self.event_id] b = json.dumps(data).encode("utf-8") return base64.b64encode(b).decode("utf-8") @staticmethod def decode(content: str) -> EventToken: """Decode an event token into a class.""" try: s = base64.b64decode(content).decode("utf-8") except binascii.Error as err: raise DecodeException from err data = json.loads(s) if not isinstance(data, list) or len(data) != 2: raise DecodeException("Unexpected data type: %s", data) return EventToken(data[0], data[1]) def __repr__(self) -> str: if not self.event_id: return "" return ( "" ) class EventImageTypeSerializationStrategy(SerializationStrategy): def serialize(self, value: EventImageContentType) -> str: return value.content_type def deserialize(self, value: str) -> EventImageContentType: return EventImageType.from_string(value) class UtcDateTimeSerializationStrategy(SerializationStrategy): """Parser to ensure all datetimes have timezones.""" def serialize(self, value: datetime.datetime) -> str: return value.isoformat() def deserialize(self, value: str) -> datetime.datetime: dt = datetime.datetime.fromisoformat(value) if dt.tzinfo is None: return dt.replace(tzinfo=datetime.timezone.utc) return dt @dataclass class ImageEventBase(DataClassDictMixin, ABC): """Base class for all image related event types.""" event_session_id: str = field(metadata=field_options(alias="eventSessionId")) """ID used to associate separate messages with a single event.""" timestamp: datetime.datetime """Timestamp when the event occurred.""" event_id: str = field(metadata=field_options(alias="eventId"), default="") """ID used to associate separate messages with a single event.""" event_image_type: EventImageContentType = field(default=EventImageType.IMAGE) """Type of the event.""" zones: list[str] = field(default_factory=list) """List of zones for the event.""" @property def event_token(self) -> str: """An identifier of this session / event combination.""" token = EventToken(self.event_session_id, self.event_id) return token.encode() @property @abstractmethod def event_type(self) -> EventType: """The type of event.""" @property def expires_at(self) -> datetime.datetime: """Timestamp when the message expires.""" return self.timestamp + datetime.timedelta(seconds=EVENT_IMAGE_EXPIRE_SECS) @property def is_expired(self) -> bool: """Return true if the event expiration has passed.""" now = datetime.datetime.now(tz=datetime.timezone.utc) return self.expires_at < now def as_dict(self) -> dict[str, Any]: """Return as a dict form that can be serialized for persistence.""" return { "event_type": self.event_type, "event_data": self.to_dict(), "timestamp": self.timestamp.isoformat(), "event_image_type": str(self.event_image_type), } @staticmethod def parse_event_dict(data: dict[str, Any]) -> ImageEventBase | None: """Parse from a persisted serialized dictionary.""" event_type = data["event_type"] event_data = data["event_data"] event = _BuildEvent(event_type, event_data) if event and "event_image_type" in data: event.event_image_type = EventImageType.from_string( data["event_image_type"] ) return event class Config(BaseConfig): serialization_strategy = { EventImageContentType: EventImageTypeSerializationStrategy(), datetime.datetime: UtcDateTimeSerializationStrategy(), } code_generation_options = [ "TO_DICT_ADD_BY_ALIAS_FLAG", "TO_DICT_ADD_OMIT_NONE_FLAG", ] allow_deserialization_not_by_alias = True @EVENT_MAP.register() @dataclass class CameraMotionEvent(ImageEventBase): """Motion has been detected by the camera.""" NAME: ClassVar[EventType] = EventType.CAMERA_MOTION event_image_type: EventImageContentType = field(default=EventImageType.IMAGE) @property def event_type(self) -> EventType: """The type of event.""" return EventType.CAMERA_MOTION @EVENT_MAP.register() @dataclass class CameraPersonEvent(ImageEventBase): """A person has been detected by the camera.""" NAME: ClassVar[EventType] = EventType.CAMERA_PERSON event_image_type: EventImageContentType = field(default=EventImageType.IMAGE) @property def event_type(self) -> EventType: """The type of event.""" return EventType.CAMERA_PERSON @EVENT_MAP.register() @dataclass class CameraSoundEvent(ImageEventBase): """Sound has been detected by the camera.""" NAME: ClassVar[EventType] = EventType.CAMERA_SOUND event_image_type: EventImageContentType = field(default=EventImageType.IMAGE) @property def event_type(self) -> EventType: """The type of event.""" return EventType.CAMERA_SOUND @EVENT_MAP.register() @dataclass class DoorbellChimeEvent(ImageEventBase): """The doorbell has been pressed.""" NAME: ClassVar[EventType] = EventType.DOORBELL_CHIME event_image_type: EventImageContentType = field(default=EventImageType.IMAGE) @property def event_type(self) -> EventType: """The type of event.""" return EventType.DOORBELL_CHIME @EVENT_MAP.register() @dataclass class CameraClipPreviewEvent(ImageEventBase): """A video clip is available for preview, without extra download.""" NAME: ClassVar[EventType] = EventType.CAMERA_CLIP_PREVIEW event_image_type: EventImageContentType = field(default=EventImageType.CLIP_PREVIEW) preview_url: str = field(metadata=field_options(alias="previewUrl"), default="") """A url 10 second frame video file in mp4 format.""" @property def event_type(self) -> EventType: """The type of event.""" return EventType.CAMERA_CLIP_PREVIEW @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Validate the event id to use a URL hash as the event id. Since clip preview events already have a url associated with them, we don't have an event id for downloading the image. """ if not (preview_url := d.get("previewUrl", d.get("preview_url"))): raise ValueError("missing required field previewUrl") d["eventId"] = hashlib.blake2b(preview_url.encode()).hexdigest() return d @property def expires_at(self) -> datetime.datetime: """Event ids do not expire.""" return self.timestamp + datetime.timedelta( seconds=CAMERA_CLIP_PREVIEW_EXPIRE_SECS ) @dataclass class RelationUpdate(DataClassDictMixin): """Represents a relational update for a resource.""" type: str """Type of relation event 'CREATED', 'UPDATED', 'DELETED'.""" subject: str """Resource that the object is now in relation with.""" object: str """Resource that triggered the event.""" def _BuildEvent( event_type: str, event_data: Mapping[str, Any] ) -> ImageEventBase | None: if event_type not in EVENT_MAP: _LOGGER.debug("Event type %s not found (%s)", event_type, EVENT_MAP.keys()) return None cls = EVENT_MAP[event_type] try: return cls.from_dict(event_data) # type: ignore except Exception as err: traceback.print_exc() _LOGGER.debug("Failed to parse event: %s (event_data=%s)", err, event_data) raise err def session_event_image_type(events: Iterable[ImageEventBase]) -> EventImageContentType: """Determine the event type to use based on the events in the session.""" for event in events: if event.event_image_type != EventImageType.IMAGE: return event.event_image_type return EventImageType.IMAGE class UpdateEventsSerializationStrategy(SerializationStrategy, use_annotations=True): """Parser to ignore invalid parent relations.""" def serialize(self, value: dict[str, ImageEventBase]) -> dict[str, Any]: return {k: v.to_dict(by_alias=True) for k, v in value.items()} def deserialize(self, value: dict[str, Any]) -> dict[str, ImageEventBase]: result = {} for event_type, event_data in value.items(): image_event = _BuildEvent(event_type, event_data) if not image_event: continue result[event_type] = image_event return result @dataclass class EventMessage(DataClassDictMixin): """Event for a change in trait value or device action.""" timestamp: datetime.datetime event_id: str = field(metadata=field_options(alias="eventId")) resource_update_name: str | None = field(default=None) resource_update_events: dict[str, ImageEventBase] | None = field(default=None) resource_update_traits: dict[str, Any] | None = field(default=None) event_thread_state: str | None = field( metadata=field_options(alias="eventThreadState"), default=None ) relation_update: RelationUpdate | None = field( metadata=field_options(alias="relationUpdate"), default=None ) _auth: AbstractAuth = field(init=False, metadata={"serialize": "omit"}) @classmethod def create_event( cls, raw_data: dict[str, Any], auth: AbstractAuth ) -> "EventMessage": """Initialize an EventMessage.""" event_data = {**raw_data} _LOGGER.debug("EventMessage raw_data=%s", event_data) if update := event_data.get(RESOURCE_UPDATE): if name := update.get(NAME): event_data["resource_update_name"] = name if events := update.get(EVENTS): timestamp = event_data.get(TIMESTAMP) for event_updates in events.values(): event_updates[TIMESTAMP] = timestamp event_data["resource_update_events"] = events if traits := update.get(TRAITS): event_data["resource_update_traits"] = traits event_data["resource_update_traits"][NAME] = update.get(NAME) event = cls.from_dict(event_data) event._auth = auth return event @property def event_sessions(self) -> dict[str, dict[str, ImageEventBase]] | None: events = self.resource_update_events if not events: return None event_sessions: dict[str, dict[str, ImageEventBase]] = {} for event_name, event in events.items(): d = event_sessions.get(event.event_session_id, {}) d[event_name] = event event_sessions[event.event_session_id] = d # Build associations between all events for event_session_id, event_dict in event_sessions.items(): event_image_type = session_event_image_type(events.values()) for event_type, event in event_dict.items(): event.event_image_type = event_image_type return event_sessions @property def raw_data(self) -> dict[str, Any]: """Return raw data for the event.""" return self.to_dict(by_alias=True) def with_events( self, event_keys: Iterable[str], merge_data: dict[str, ImageEventBase] | None = None, ) -> EventMessage: """Create a new EventMessage minus some existing events by key.""" new_message = EventMessage.create_event(self.to_dict(by_alias=True), self._auth) if not merge_data: merge_data = {} new_events = {} for key in event_keys: if ( new_message.resource_update_events and key in new_message.resource_update_events ): new_events[key] = new_message.resource_update_events[key] elif merge_data and key in merge_data: new_events[key] = merge_data[key] new_message.resource_update_events = new_events return new_message @property def is_thread_ended(self) -> bool: """Return true if the message indicates the thread is ended.""" return self.event_thread_state == EVENT_THREAD_STATE_ENDED def __repr__(self) -> str: """Debug information.""" return f"EventMessage{self.to_dict()}" class Config(BaseConfig): serialization_strategy = { dict[str, ImageEventBase]: UpdateEventsSerializationStrategy(), datetime.datetime: UtcDateTimeSerializationStrategy(), } code_generation_options = [ "TO_DICT_ADD_BY_ALIAS_FLAG", "TO_DICT_ADD_OMIT_NONE_FLAG", ]