"""A device from the Smart Device Management API.""" from __future__ import annotations import datetime import logging from typing import Any, Awaitable, Callable from dataclasses import dataclass, field, fields, asdict from mashumaro import field_options, DataClassDictMixin from mashumaro.config import BaseConfig from mashumaro.types import SerializationStrategy from . import camera_traits, device_traits, doorbell_traits, thermostat_traits from .auth import AbstractAuth from .doorbell_traits import DoorbellChimeTrait from .diagnostics import Diagnostics, redact_data from .event import EventMessage, EventProcessingError from .event_media import EventMediaManager from .traits import Command from .model import TraitDataClass, SDM_PREFIX, TRAITS _LOGGER = logging.getLogger(__name__) @dataclass class ParentRelation(DataClassDictMixin): """Represents the parent structure/room of the current resource.""" parent: str display_name: str = field(metadata=field_options(alias="displayName")) class Config(BaseConfig): serialize_by_alias = True @dataclass class TraitTypes(TraitDataClass): """Data model for parsing traits in the Google Nest SDM API.""" # Device Traits connectivity: device_traits.ConnectivityTrait | None = field( metadata=field_options( alias="sdm.devices.traits.Connectivity", ), default=None, ) fan: device_traits.FanTrait | None = field( metadata=field_options( alias="sdm.devices.traits.Fan", ), default=None, ) info: device_traits.InfoTrait | None = field( metadata=field_options( alias="sdm.devices.traits.Info", ), default=None, ) humidity: device_traits.HumidityTrait | None = field( metadata=field_options( alias="sdm.devices.traits.Humidity", ), default=None, ) temperature: device_traits.TemperatureTrait | None = field( metadata=field_options( alias="sdm.devices.traits.Temperature", ), default=None, ) # Thermostat Traits thermostat_eco: thermostat_traits.ThermostatEcoTrait | None = field( metadata=field_options( alias="sdm.devices.traits.ThermostatEco", ), default=None, ) thermostat_hvac: thermostat_traits.ThermostatHvacTrait | None = field( metadata=field_options( alias="sdm.devices.traits.ThermostatHvac", ), default=None, ) thermostat_mode: thermostat_traits.ThermostatModeTrait | None = field( metadata=field_options( alias="sdm.devices.traits.ThermostatMode", ), default=None, ) thermostat_temperature_setpoint: ( thermostat_traits.ThermostatTemperatureSetpointTrait | None ) = field( metadata=field_options( alias="sdm.devices.traits.ThermostatTemperatureSetpoint", ), default=None, ) # # Camera Traits camera_image: camera_traits.CameraImageTrait | None = field( metadata=field_options( alias="sdm.devices.traits.CameraImage", ), default=None, ) camera_live_stream: camera_traits.CameraLiveStreamTrait | None = field( metadata=field_options(alias="sdm.devices.traits.CameraLiveStream"), default=None, ) camera_event_image: camera_traits.CameraEventImageTrait | None = field( metadata=field_options( alias="sdm.devices.traits.CameraEventImage", ), default=None, ) camera_motion: camera_traits.CameraMotionTrait | None = field( metadata=field_options( alias="sdm.devices.traits.CameraMotion", ), default=None, ) camera_person: camera_traits.CameraPersonTrait | None = field( metadata=field_options( alias="sdm.devices.traits.CameraPerson", ), default=None, ) camera_sound: camera_traits.CameraSoundTrait | None = field( metadata=field_options( alias="sdm.devices.traits.CameraSound", ), default=None, ) camera_clip_preview: camera_traits.CameraClipPreviewTrait | None = field( metadata=field_options( alias="sdm.devices.traits.CameraClipPreview", ), default=None, ) # # Doorbell Traits doorbell_chime: doorbell_traits.DoorbellChimeTrait | None = field( metadata=field_options( alias="sdm.devices.traits.DoorbellChime", ), default=None, ) class ParentRelationsSerializationStrategy(SerializationStrategy, use_annotations=True): """Parser to ignore invalid parent relations.""" def serialize(self, value: list[ParentRelation]) -> list[dict[str, Any]]: return [x.to_dict() for x in value] def deserialize(self, value: list[dict[str, Any]]) -> list[ParentRelation]: return [ ParentRelation.from_dict(relation) for relation in value if "parent" in relation and "displayName" in relation ] def _name_required() -> str: """Raise an error if the name field is not provided. This is a workaround for the fact that dataclasses children can't have default fields out of order from the subclass. """ raise ValueError("Field 'name' is required") @dataclass class Device(TraitTypes): """Class that represents a device object in the Google Nest SDM API.""" name: str = field(default_factory=_name_required) """Resource name of the device such as 'enterprises/XYZ/devices/123'.""" type: str | None = None """Type of device for display purposes. The device type should not be used to deduce or infer functionality of the actual device it is assigned to. Instead, use the returned traits for the device. """ relations: list[ParentRelation] = field( metadata=field_options(alias="parentRelations"), default_factory=list ) """Represents the parent structure or room of the device.""" _auth: AbstractAuth = field(init=False, metadata={"serialize": "omit"}) _diagnostics: Diagnostics = field(init=False, metadata={"serialize": "omit"}) _event_media_manager: EventMediaManager = field( init=False, metadata={"serialize": "omit"} ) _callbacks: list[Callable[[EventMessage], Awaitable[None]]] = field( init=False, metadata={"serialize": "omit"}, default_factory=list ) _trait_event_ts: dict[str, datetime.datetime] = field( init=False, metadata={"serialize": "omit"}, default_factory=dict ) @staticmethod def MakeDevice(raw_data: dict[str, Any], auth: AbstractAuth) -> Device: """Create a device with the appropriate traits.""" # Hack for incorrect nest API response values if (type := raw_data.get("type")) and type == "sdm.devices.types.DOORBELL": if TRAITS not in raw_data: raw_data[TRAITS] = {} raw_data[TRAITS][DoorbellChimeTrait.NAME] = {} device: Device = Device.parse_trait_object(raw_data) device._auth = auth device._diagnostics = Diagnostics() cmd = Command(raw_data["name"], auth, device._diagnostics.subkey("command")) for trait in device.traits.values(): if hasattr(trait, "_cmd"): trait._cmd = cmd event_traits = { trait.EVENT_NAME for trait in device.traits.values() if hasattr(trait, "EVENT_NAME") } device._event_media_manager = EventMediaManager( device.name or "", device.traits, event_traits, diagnostics=device._diagnostics.subkey("event_media"), ) return device def add_update_listener(self, target: Callable[[], None]) -> Callable[[], None]: """Register a simple event listener notified on updates. This will not block on media being fetched. To wait for media, use the callback form the `EventMediaManager`. The return value is a callable that will unregister the callback. """ async def handle_event(event_message: EventMessage) -> None: target() return self.add_event_callback(handle_event) def add_event_callback( self, target: Callable[[EventMessage], Awaitable[None]] ) -> Callable[[], None]: """Register an event callback for updates to this device. This will not block on media being fetched. To wait for media, use the callback form the `EventMediaManager`. The return value is a callable that will unregister the callback. """ self._callbacks.append(target) def remove_callback() -> None: """Remove the event_callback.""" self._callbacks.remove(target) return remove_callback async def async_handle_event(self, event_message: EventMessage) -> None: """Process an event from the pubsub subscriber. This will invoke any directly registered callbacks (before fetching media) as well as any callbacks registered with the event media manager that fire post-media. """ _LOGGER.debug( "Processing update %s @ %s", event_message.event_id, event_message.timestamp ) if not event_message.resource_update_name: raise EventProcessingError("Event was not resource update event") if self.name != event_message.resource_update_name: raise EventProcessingError( f"Mismatch {self.name} != {event_message.resource_update_name}" ) self._async_handle_traits(event_message) for callback in self._callbacks: await callback(event_message) await self._event_media_manager.async_handle_events(event_message) def _async_handle_traits(self, event_message: EventMessage) -> None: traits = event_message.resource_update_traits if not traits: return # Parse the traits using a separate object, then overwrite # each present field with an updated copy of the original trait with # the new fields merged in. _LOGGER.debug("Trait update %s", traits) parsed_traits = TraitTypes.parse_trait_object({TRAITS: traits}) self._async_update_traits(parsed_traits, event_message.timestamp) def _async_update_traits( self, parsed_traits: TraitTypes, timestamp: datetime.datetime ) -> None: if timestamp.tzinfo is None: timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) for trait_field in fields(parsed_traits): if ( (alias := trait_field.metadata.get("alias")) is None or not alias.startswith(SDM_PREFIX) or not (new := getattr(parsed_traits, trait_field.name)) ): continue # Discard updates to traits that are newer than the update if (ts := self._trait_timestamp(trait_field.name)) and ts > timestamp: _LOGGER.debug("Discarding stale update (%s)", timestamp) continue # Only merge updates into existing models, updating the existing # fields present in the update trait if not (existing := getattr(self, trait_field.name)): continue for k, v in asdict(new).items(): if v is not None: setattr(existing, k, v) self._trait_event_ts[trait_field.name] = timestamp def merge_from_update(self, new_device: Device) -> None: """Merge fields from an updated device object. This is used when refreshing the device list from the API. """ self._async_update_traits(new_device, datetime.datetime.utcnow()) def _trait_timestamp(self, trait_field_name: str) -> datetime.datetime | None: """Get the last update timestamp for a given trait field.""" if (ts := self._trait_event_ts.get(trait_field_name)) is None: return None if ts.tzinfo is None: return ts.replace(tzinfo=datetime.timezone.utc) return ts @property def event_media_manager(self) -> EventMediaManager: return self._event_media_manager @property def parent_relations(self) -> dict: """Room or structure for the device.""" return {relation.parent: relation.display_name for relation in self.relations} def delete_relation(self, parent: str) -> None: """Remove a device relationship with the parent.""" self.relations = [ relation for relation in self.relations if relation.parent != parent ] def create_relation(self, relation: ParentRelation) -> None: """Add a new device relation.""" self.relations.append(relation) def get_diagnostics(self) -> dict[str, Any]: return { "data": redact_data(self.raw_data), **self._diagnostics.as_dict(), } class Config(TraitTypes.Config): serialization_strategy = { list[ParentRelation]: ParentRelationsSerializationStrategy(), }