google_nest_sdm.device

A device from the Smart Device Management API.

  1"""A device from the Smart Device Management API."""
  2
  3from __future__ import annotations
  4
  5import datetime
  6import logging
  7from typing import Any, Awaitable, Callable
  8from dataclasses import dataclass, field, fields, asdict
  9
 10from mashumaro import field_options, DataClassDictMixin
 11from mashumaro.config import BaseConfig
 12from mashumaro.types import SerializationStrategy
 13
 14from . import camera_traits, device_traits, doorbell_traits, thermostat_traits
 15from .auth import AbstractAuth
 16from .doorbell_traits import DoorbellChimeTrait
 17from .diagnostics import Diagnostics, redact_data
 18from .event import EventMessage, EventProcessingError
 19from .event_media import EventMediaManager
 20from .traits import Command
 21from .model import TraitDataClass, SDM_PREFIX, TRAITS
 22
 23_LOGGER = logging.getLogger(__name__)
 24
 25
 26@dataclass
 27class ParentRelation(DataClassDictMixin):
 28    """Represents the parent structure/room of the current resource."""
 29
 30    parent: str
 31    display_name: str = field(metadata=field_options(alias="displayName"))
 32
 33    class Config(BaseConfig):
 34        serialize_by_alias = True
 35
 36
 37@dataclass
 38class TraitTypes(TraitDataClass):
 39    """Data model for parsing traits in the Google Nest SDM API."""
 40
 41    # Device Traits
 42    connectivity: device_traits.ConnectivityTrait | None = field(
 43        metadata=field_options(
 44            alias="sdm.devices.traits.Connectivity",
 45        ),
 46        default=None,
 47    )
 48    fan: device_traits.FanTrait | None = field(
 49        metadata=field_options(
 50            alias="sdm.devices.traits.Fan",
 51        ),
 52        default=None,
 53    )
 54    info: device_traits.InfoTrait | None = field(
 55        metadata=field_options(
 56            alias="sdm.devices.traits.Info",
 57        ),
 58        default=None,
 59    )
 60    humidity: device_traits.HumidityTrait | None = field(
 61        metadata=field_options(
 62            alias="sdm.devices.traits.Humidity",
 63        ),
 64        default=None,
 65    )
 66    temperature: device_traits.TemperatureTrait | None = field(
 67        metadata=field_options(
 68            alias="sdm.devices.traits.Temperature",
 69        ),
 70        default=None,
 71    )
 72
 73    # Thermostat Traits
 74    thermostat_eco: thermostat_traits.ThermostatEcoTrait | None = field(
 75        metadata=field_options(
 76            alias="sdm.devices.traits.ThermostatEco",
 77        ),
 78        default=None,
 79    )
 80    thermostat_hvac: thermostat_traits.ThermostatHvacTrait | None = field(
 81        metadata=field_options(
 82            alias="sdm.devices.traits.ThermostatHvac",
 83        ),
 84        default=None,
 85    )
 86    thermostat_mode: thermostat_traits.ThermostatModeTrait | None = field(
 87        metadata=field_options(
 88            alias="sdm.devices.traits.ThermostatMode",
 89        ),
 90        default=None,
 91    )
 92    thermostat_temperature_setpoint: (
 93        thermostat_traits.ThermostatTemperatureSetpointTrait | None
 94    ) = field(
 95        metadata=field_options(
 96            alias="sdm.devices.traits.ThermostatTemperatureSetpoint",
 97        ),
 98        default=None,
 99    )
100
101    # # Camera Traits
102    camera_image: camera_traits.CameraImageTrait | None = field(
103        metadata=field_options(
104            alias="sdm.devices.traits.CameraImage",
105        ),
106        default=None,
107    )
108    camera_live_stream: camera_traits.CameraLiveStreamTrait | None = field(
109        metadata=field_options(alias="sdm.devices.traits.CameraLiveStream"),
110        default=None,
111    )
112    camera_event_image: camera_traits.CameraEventImageTrait | None = field(
113        metadata=field_options(
114            alias="sdm.devices.traits.CameraEventImage",
115        ),
116        default=None,
117    )
118    camera_motion: camera_traits.CameraMotionTrait | None = field(
119        metadata=field_options(
120            alias="sdm.devices.traits.CameraMotion",
121        ),
122        default=None,
123    )
124    camera_person: camera_traits.CameraPersonTrait | None = field(
125        metadata=field_options(
126            alias="sdm.devices.traits.CameraPerson",
127        ),
128        default=None,
129    )
130    camera_sound: camera_traits.CameraSoundTrait | None = field(
131        metadata=field_options(
132            alias="sdm.devices.traits.CameraSound",
133        ),
134        default=None,
135    )
136    camera_clip_preview: camera_traits.CameraClipPreviewTrait | None = field(
137        metadata=field_options(
138            alias="sdm.devices.traits.CameraClipPreview",
139        ),
140        default=None,
141    )
142
143    # # Doorbell Traits
144    doorbell_chime: doorbell_traits.DoorbellChimeTrait | None = field(
145        metadata=field_options(
146            alias="sdm.devices.traits.DoorbellChime",
147        ),
148        default=None,
149    )
150
151
152class ParentRelationsSerializationStrategy(SerializationStrategy, use_annotations=True):
153    """Parser to ignore invalid parent relations."""
154
155    def serialize(self, value: list[ParentRelation]) -> list[dict[str, Any]]:
156        return [x.to_dict() for x in value]
157
158    def deserialize(self, value: list[dict[str, Any]]) -> list[ParentRelation]:
159        return [
160            ParentRelation.from_dict(relation)
161            for relation in value
162            if "parent" in relation and "displayName" in relation
163        ]
164
165
166def _name_required() -> str:
167    """Raise an error if the name field is not provided.
168
169    This is a workaround for the fact that dataclasses children can't have
170    default fields out of order from the subclass.
171    """
172    raise ValueError("Field 'name' is required")
173
174
175@dataclass
176class Device(TraitTypes):
177    """Class that represents a device object in the Google Nest SDM API."""
178
179    name: str = field(default_factory=_name_required)
180    """Resource name of the device such as 'enterprises/XYZ/devices/123'."""
181
182    type: str | None = None
183    """Type of device for display purposes.
184
185    The device type should not be used to deduce or infer functionality of
186    the actual device it is assigned to. Instead, use the returned traits for
187    the device.
188    """
189
190    relations: list[ParentRelation] = field(
191        metadata=field_options(alias="parentRelations"), default_factory=list
192    )
193    """Represents the parent structure or room of the device."""
194
195    _auth: AbstractAuth = field(init=False, metadata={"serialize": "omit"})
196    _diagnostics: Diagnostics = field(init=False, metadata={"serialize": "omit"})
197    _event_media_manager: EventMediaManager = field(
198        init=False, metadata={"serialize": "omit"}
199    )
200    _callbacks: list[Callable[[EventMessage], Awaitable[None]]] = field(
201        init=False, metadata={"serialize": "omit"}, default_factory=list
202    )
203    _trait_event_ts: dict[str, datetime.datetime] = field(
204        init=False, metadata={"serialize": "omit"}, default_factory=dict
205    )
206
207    @staticmethod
208    def MakeDevice(raw_data: dict[str, Any], auth: AbstractAuth) -> Device:
209        """Create a device with the appropriate traits."""
210
211        # Hack for incorrect nest API response values
212        if (type := raw_data.get("type")) and type == "sdm.devices.types.DOORBELL":
213            if TRAITS not in raw_data:
214                raw_data[TRAITS] = {}
215            raw_data[TRAITS][DoorbellChimeTrait.NAME] = {}
216
217        device: Device = Device.parse_trait_object(raw_data)
218        device._auth = auth
219        device._diagnostics = Diagnostics()
220        cmd = Command(raw_data["name"], auth, device._diagnostics.subkey("command"))
221        for trait in device.traits.values():
222            if hasattr(trait, "_cmd"):
223                trait._cmd = cmd
224
225        event_traits = {
226            trait.EVENT_NAME
227            for trait in device.traits.values()
228            if hasattr(trait, "EVENT_NAME")
229        }
230        device._event_media_manager = EventMediaManager(
231            device.name or "",
232            device.traits,
233            event_traits,
234            diagnostics=device._diagnostics.subkey("event_media"),
235        )
236        return device
237
238    def add_update_listener(self, target: Callable[[], None]) -> Callable[[], None]:
239        """Register a simple event listener notified on updates.
240
241        This will not block on media being fetched. To wait for media, use
242        the callback form the `EventMediaManager`.
243
244        The return value is a callable that will unregister the callback.
245        """
246
247        async def handle_event(event_message: EventMessage) -> None:
248            target()
249
250        return self.add_event_callback(handle_event)
251
252    def add_event_callback(
253        self, target: Callable[[EventMessage], Awaitable[None]]
254    ) -> Callable[[], None]:
255        """Register an event callback for updates to this device.
256
257        This will not block on media being fetched. To wait for media, use
258        the callback form the `EventMediaManager`.
259
260        The return value is a callable that will unregister the callback.
261        """
262        self._callbacks.append(target)
263
264        def remove_callback() -> None:
265            """Remove the event_callback."""
266            self._callbacks.remove(target)
267
268        return remove_callback
269
270    async def async_handle_event(self, event_message: EventMessage) -> None:
271        """Process an event from the pubsub subscriber.
272
273        This will invoke any directly registered callbacks (before fetching media)
274        as well as any callbacks registered with the event media manager that
275        fire post-media.
276        """
277        _LOGGER.debug(
278            "Processing update %s @ %s", event_message.event_id, event_message.timestamp
279        )
280        if not event_message.resource_update_name:
281            raise EventProcessingError("Event was not resource update event")
282        if self.name != event_message.resource_update_name:
283            raise EventProcessingError(
284                f"Mismatch {self.name} != {event_message.resource_update_name}"
285            )
286        self._async_handle_traits(event_message)
287        for callback in self._callbacks:
288            await callback(event_message)
289        await self._event_media_manager.async_handle_events(event_message)
290
291    def _async_handle_traits(self, event_message: EventMessage) -> None:
292        traits = event_message.resource_update_traits
293        if not traits:
294            return
295        # Parse the traits using a separate object, then overwrite
296        # each present field with an updated copy of the original trait with
297        # the new fields merged in.
298        _LOGGER.debug("Trait update %s", traits)
299        parsed_traits = TraitTypes.parse_trait_object({TRAITS: traits})
300        self._async_update_traits(parsed_traits, event_message.timestamp)
301
302    def _async_update_traits(
303        self, parsed_traits: TraitTypes, timestamp: datetime.datetime
304    ) -> None:
305        if timestamp.tzinfo is None:
306            timestamp = timestamp.replace(tzinfo=datetime.UTC)
307        for trait_field in fields(parsed_traits):
308            if (
309                (alias := trait_field.metadata.get("alias")) is None
310                or not alias.startswith(SDM_PREFIX)
311                or not (new := getattr(parsed_traits, trait_field.name))
312            ):
313                continue
314            # Discard updates to traits that are newer than the update
315            if (ts := self._trait_timestamp(trait_field.name)) and ts > timestamp:
316                _LOGGER.debug("Discarding stale update (%s)", timestamp)
317                continue
318
319            # Only merge updates into existing models, updating the existing
320            # fields present in the update trait
321            if not (existing := getattr(self, trait_field.name)):
322                continue
323            for k, v in asdict(new).items():
324                if v is not None:
325                    setattr(existing, k, v)
326            self._trait_event_ts[trait_field.name] = timestamp
327
328    def merge_from_update(self, new_device: Device) -> None:
329        """Merge fields from an updated device object.
330
331        This is used when refreshing the device list from the API.
332        """
333        self._async_update_traits(new_device, datetime.datetime.now(datetime.UTC))
334
335    def _trait_timestamp(self, trait_field_name: str) -> datetime.datetime | None:
336        """Get the last update timestamp for a given trait field."""
337        if (ts := self._trait_event_ts.get(trait_field_name)) is None:
338            return None
339        if ts.tzinfo is None:
340            return ts.replace(tzinfo=datetime.UTC)
341        return ts
342
343    @property
344    def event_media_manager(self) -> EventMediaManager:
345        return self._event_media_manager
346
347    @property
348    def parent_relations(self) -> dict:
349        """Room or structure for the device."""
350        return {relation.parent: relation.display_name for relation in self.relations}
351
352    def delete_relation(self, parent: str) -> None:
353        """Remove a device relationship with the parent."""
354        self.relations = [
355            relation for relation in self.relations if relation.parent != parent
356        ]
357
358    def create_relation(self, relation: ParentRelation) -> None:
359        """Add a new device relation."""
360        self.relations.append(relation)
361
362    def get_diagnostics(self) -> dict[str, Any]:
363        return {
364            "data": redact_data(self.raw_data),
365            **self._diagnostics.as_dict(),
366        }
367
368    class Config(TraitTypes.Config):
369        serialization_strategy = {
370            list[ParentRelation]: ParentRelationsSerializationStrategy(),
371        }
@dataclass
class ParentRelation(mashumaro.mixins.dict.DataClassDictMixin):
27@dataclass
28class ParentRelation(DataClassDictMixin):
29    """Represents the parent structure/room of the current resource."""
30
31    parent: str
32    display_name: str = field(metadata=field_options(alias="displayName"))
33
34    class Config(BaseConfig):
35        serialize_by_alias = True

Represents the parent structure/room of the current resource.

ParentRelation(parent: str, display_name: str)
parent: str
display_name: str
def to_dict(self):

The type of the None singleton.

def from_dict(cls, d, *, dialect=None):

The type of the None singleton.

class ParentRelation.Config(mashumaro.config.BaseConfig):
34    class Config(BaseConfig):
35        serialize_by_alias = True
serialize_by_alias = True
@dataclass
class TraitTypes(google_nest_sdm.model.TraitDataClass):
 38@dataclass
 39class TraitTypes(TraitDataClass):
 40    """Data model for parsing traits in the Google Nest SDM API."""
 41
 42    # Device Traits
 43    connectivity: device_traits.ConnectivityTrait | None = field(
 44        metadata=field_options(
 45            alias="sdm.devices.traits.Connectivity",
 46        ),
 47        default=None,
 48    )
 49    fan: device_traits.FanTrait | None = field(
 50        metadata=field_options(
 51            alias="sdm.devices.traits.Fan",
 52        ),
 53        default=None,
 54    )
 55    info: device_traits.InfoTrait | None = field(
 56        metadata=field_options(
 57            alias="sdm.devices.traits.Info",
 58        ),
 59        default=None,
 60    )
 61    humidity: device_traits.HumidityTrait | None = field(
 62        metadata=field_options(
 63            alias="sdm.devices.traits.Humidity",
 64        ),
 65        default=None,
 66    )
 67    temperature: device_traits.TemperatureTrait | None = field(
 68        metadata=field_options(
 69            alias="sdm.devices.traits.Temperature",
 70        ),
 71        default=None,
 72    )
 73
 74    # Thermostat Traits
 75    thermostat_eco: thermostat_traits.ThermostatEcoTrait | None = field(
 76        metadata=field_options(
 77            alias="sdm.devices.traits.ThermostatEco",
 78        ),
 79        default=None,
 80    )
 81    thermostat_hvac: thermostat_traits.ThermostatHvacTrait | None = field(
 82        metadata=field_options(
 83            alias="sdm.devices.traits.ThermostatHvac",
 84        ),
 85        default=None,
 86    )
 87    thermostat_mode: thermostat_traits.ThermostatModeTrait | None = field(
 88        metadata=field_options(
 89            alias="sdm.devices.traits.ThermostatMode",
 90        ),
 91        default=None,
 92    )
 93    thermostat_temperature_setpoint: (
 94        thermostat_traits.ThermostatTemperatureSetpointTrait | None
 95    ) = field(
 96        metadata=field_options(
 97            alias="sdm.devices.traits.ThermostatTemperatureSetpoint",
 98        ),
 99        default=None,
100    )
101
102    # # Camera Traits
103    camera_image: camera_traits.CameraImageTrait | None = field(
104        metadata=field_options(
105            alias="sdm.devices.traits.CameraImage",
106        ),
107        default=None,
108    )
109    camera_live_stream: camera_traits.CameraLiveStreamTrait | None = field(
110        metadata=field_options(alias="sdm.devices.traits.CameraLiveStream"),
111        default=None,
112    )
113    camera_event_image: camera_traits.CameraEventImageTrait | None = field(
114        metadata=field_options(
115            alias="sdm.devices.traits.CameraEventImage",
116        ),
117        default=None,
118    )
119    camera_motion: camera_traits.CameraMotionTrait | None = field(
120        metadata=field_options(
121            alias="sdm.devices.traits.CameraMotion",
122        ),
123        default=None,
124    )
125    camera_person: camera_traits.CameraPersonTrait | None = field(
126        metadata=field_options(
127            alias="sdm.devices.traits.CameraPerson",
128        ),
129        default=None,
130    )
131    camera_sound: camera_traits.CameraSoundTrait | None = field(
132        metadata=field_options(
133            alias="sdm.devices.traits.CameraSound",
134        ),
135        default=None,
136    )
137    camera_clip_preview: camera_traits.CameraClipPreviewTrait | None = field(
138        metadata=field_options(
139            alias="sdm.devices.traits.CameraClipPreview",
140        ),
141        default=None,
142    )
143
144    # # Doorbell Traits
145    doorbell_chime: doorbell_traits.DoorbellChimeTrait | None = field(
146        metadata=field_options(
147            alias="sdm.devices.traits.DoorbellChime",
148        ),
149        default=None,
150    )

Data model for parsing traits in the Google Nest SDM API.

thermostat_temperature_setpoint: google_nest_sdm.thermostat_traits.ThermostatTemperatureSetpointTrait | None = None
camera_live_stream: google_nest_sdm.camera_traits.CameraLiveStreamTrait | None = None
camera_event_image: google_nest_sdm.camera_traits.CameraEventImageTrait | None = None
camera_clip_preview: google_nest_sdm.camera_traits.CameraClipPreviewTrait | None = None
def to_dict(self, *, omit_none=False, by_alias=True):

The type of the None singleton.

def from_dict(cls, d, *, dialect=None):

The type of the None singleton.

class ParentRelationsSerializationStrategy(mashumaro.types.SerializationStrategy):
153class ParentRelationsSerializationStrategy(SerializationStrategy, use_annotations=True):
154    """Parser to ignore invalid parent relations."""
155
156    def serialize(self, value: list[ParentRelation]) -> list[dict[str, Any]]:
157        return [x.to_dict() for x in value]
158
159    def deserialize(self, value: list[dict[str, Any]]) -> list[ParentRelation]:
160        return [
161            ParentRelation.from_dict(relation)
162            for relation in value
163            if "parent" in relation and "displayName" in relation
164        ]

Parser to ignore invalid parent relations.

def serialize( self, value: list[ParentRelation]) -> list[dict[str, typing.Any]]:
156    def serialize(self, value: list[ParentRelation]) -> list[dict[str, Any]]:
157        return [x.to_dict() for x in value]
def deserialize( self, value: list[dict[str, typing.Any]]) -> list[ParentRelation]:
159    def deserialize(self, value: list[dict[str, Any]]) -> list[ParentRelation]:
160        return [
161            ParentRelation.from_dict(relation)
162            for relation in value
163            if "parent" in relation and "displayName" in relation
164        ]
@dataclass
class Device(TraitTypes):
176@dataclass
177class Device(TraitTypes):
178    """Class that represents a device object in the Google Nest SDM API."""
179
180    name: str = field(default_factory=_name_required)
181    """Resource name of the device such as 'enterprises/XYZ/devices/123'."""
182
183    type: str | None = None
184    """Type of device for display purposes.
185
186    The device type should not be used to deduce or infer functionality of
187    the actual device it is assigned to. Instead, use the returned traits for
188    the device.
189    """
190
191    relations: list[ParentRelation] = field(
192        metadata=field_options(alias="parentRelations"), default_factory=list
193    )
194    """Represents the parent structure or room of the device."""
195
196    _auth: AbstractAuth = field(init=False, metadata={"serialize": "omit"})
197    _diagnostics: Diagnostics = field(init=False, metadata={"serialize": "omit"})
198    _event_media_manager: EventMediaManager = field(
199        init=False, metadata={"serialize": "omit"}
200    )
201    _callbacks: list[Callable[[EventMessage], Awaitable[None]]] = field(
202        init=False, metadata={"serialize": "omit"}, default_factory=list
203    )
204    _trait_event_ts: dict[str, datetime.datetime] = field(
205        init=False, metadata={"serialize": "omit"}, default_factory=dict
206    )
207
208    @staticmethod
209    def MakeDevice(raw_data: dict[str, Any], auth: AbstractAuth) -> Device:
210        """Create a device with the appropriate traits."""
211
212        # Hack for incorrect nest API response values
213        if (type := raw_data.get("type")) and type == "sdm.devices.types.DOORBELL":
214            if TRAITS not in raw_data:
215                raw_data[TRAITS] = {}
216            raw_data[TRAITS][DoorbellChimeTrait.NAME] = {}
217
218        device: Device = Device.parse_trait_object(raw_data)
219        device._auth = auth
220        device._diagnostics = Diagnostics()
221        cmd = Command(raw_data["name"], auth, device._diagnostics.subkey("command"))
222        for trait in device.traits.values():
223            if hasattr(trait, "_cmd"):
224                trait._cmd = cmd
225
226        event_traits = {
227            trait.EVENT_NAME
228            for trait in device.traits.values()
229            if hasattr(trait, "EVENT_NAME")
230        }
231        device._event_media_manager = EventMediaManager(
232            device.name or "",
233            device.traits,
234            event_traits,
235            diagnostics=device._diagnostics.subkey("event_media"),
236        )
237        return device
238
239    def add_update_listener(self, target: Callable[[], None]) -> Callable[[], None]:
240        """Register a simple event listener notified on updates.
241
242        This will not block on media being fetched. To wait for media, use
243        the callback form the `EventMediaManager`.
244
245        The return value is a callable that will unregister the callback.
246        """
247
248        async def handle_event(event_message: EventMessage) -> None:
249            target()
250
251        return self.add_event_callback(handle_event)
252
253    def add_event_callback(
254        self, target: Callable[[EventMessage], Awaitable[None]]
255    ) -> Callable[[], None]:
256        """Register an event callback for updates to this device.
257
258        This will not block on media being fetched. To wait for media, use
259        the callback form the `EventMediaManager`.
260
261        The return value is a callable that will unregister the callback.
262        """
263        self._callbacks.append(target)
264
265        def remove_callback() -> None:
266            """Remove the event_callback."""
267            self._callbacks.remove(target)
268
269        return remove_callback
270
271    async def async_handle_event(self, event_message: EventMessage) -> None:
272        """Process an event from the pubsub subscriber.
273
274        This will invoke any directly registered callbacks (before fetching media)
275        as well as any callbacks registered with the event media manager that
276        fire post-media.
277        """
278        _LOGGER.debug(
279            "Processing update %s @ %s", event_message.event_id, event_message.timestamp
280        )
281        if not event_message.resource_update_name:
282            raise EventProcessingError("Event was not resource update event")
283        if self.name != event_message.resource_update_name:
284            raise EventProcessingError(
285                f"Mismatch {self.name} != {event_message.resource_update_name}"
286            )
287        self._async_handle_traits(event_message)
288        for callback in self._callbacks:
289            await callback(event_message)
290        await self._event_media_manager.async_handle_events(event_message)
291
292    def _async_handle_traits(self, event_message: EventMessage) -> None:
293        traits = event_message.resource_update_traits
294        if not traits:
295            return
296        # Parse the traits using a separate object, then overwrite
297        # each present field with an updated copy of the original trait with
298        # the new fields merged in.
299        _LOGGER.debug("Trait update %s", traits)
300        parsed_traits = TraitTypes.parse_trait_object({TRAITS: traits})
301        self._async_update_traits(parsed_traits, event_message.timestamp)
302
303    def _async_update_traits(
304        self, parsed_traits: TraitTypes, timestamp: datetime.datetime
305    ) -> None:
306        if timestamp.tzinfo is None:
307            timestamp = timestamp.replace(tzinfo=datetime.UTC)
308        for trait_field in fields(parsed_traits):
309            if (
310                (alias := trait_field.metadata.get("alias")) is None
311                or not alias.startswith(SDM_PREFIX)
312                or not (new := getattr(parsed_traits, trait_field.name))
313            ):
314                continue
315            # Discard updates to traits that are newer than the update
316            if (ts := self._trait_timestamp(trait_field.name)) and ts > timestamp:
317                _LOGGER.debug("Discarding stale update (%s)", timestamp)
318                continue
319
320            # Only merge updates into existing models, updating the existing
321            # fields present in the update trait
322            if not (existing := getattr(self, trait_field.name)):
323                continue
324            for k, v in asdict(new).items():
325                if v is not None:
326                    setattr(existing, k, v)
327            self._trait_event_ts[trait_field.name] = timestamp
328
329    def merge_from_update(self, new_device: Device) -> None:
330        """Merge fields from an updated device object.
331
332        This is used when refreshing the device list from the API.
333        """
334        self._async_update_traits(new_device, datetime.datetime.now(datetime.UTC))
335
336    def _trait_timestamp(self, trait_field_name: str) -> datetime.datetime | None:
337        """Get the last update timestamp for a given trait field."""
338        if (ts := self._trait_event_ts.get(trait_field_name)) is None:
339            return None
340        if ts.tzinfo is None:
341            return ts.replace(tzinfo=datetime.UTC)
342        return ts
343
344    @property
345    def event_media_manager(self) -> EventMediaManager:
346        return self._event_media_manager
347
348    @property
349    def parent_relations(self) -> dict:
350        """Room or structure for the device."""
351        return {relation.parent: relation.display_name for relation in self.relations}
352
353    def delete_relation(self, parent: str) -> None:
354        """Remove a device relationship with the parent."""
355        self.relations = [
356            relation for relation in self.relations if relation.parent != parent
357        ]
358
359    def create_relation(self, relation: ParentRelation) -> None:
360        """Add a new device relation."""
361        self.relations.append(relation)
362
363    def get_diagnostics(self) -> dict[str, Any]:
364        return {
365            "data": redact_data(self.raw_data),
366            **self._diagnostics.as_dict(),
367        }
368
369    class Config(TraitTypes.Config):
370        serialization_strategy = {
371            list[ParentRelation]: ParentRelationsSerializationStrategy(),
372        }

Class that represents a device object in the Google Nest SDM API.

Device( connectivity: google_nest_sdm.device_traits.ConnectivityTrait | None = None, fan: google_nest_sdm.device_traits.FanTrait | None = None, info: google_nest_sdm.device_traits.InfoTrait | None = None, humidity: google_nest_sdm.device_traits.HumidityTrait | None = None, temperature: google_nest_sdm.device_traits.TemperatureTrait | None = None, thermostat_eco: google_nest_sdm.thermostat_traits.ThermostatEcoTrait | None = None, thermostat_hvac: google_nest_sdm.thermostat_traits.ThermostatHvacTrait | None = None, thermostat_mode: google_nest_sdm.thermostat_traits.ThermostatModeTrait | None = None, thermostat_temperature_setpoint: google_nest_sdm.thermostat_traits.ThermostatTemperatureSetpointTrait | None = None, camera_image: google_nest_sdm.camera_traits.CameraImageTrait | None = None, camera_live_stream: google_nest_sdm.camera_traits.CameraLiveStreamTrait | None = None, camera_event_image: google_nest_sdm.camera_traits.CameraEventImageTrait | None = None, camera_motion: google_nest_sdm.camera_traits.CameraMotionTrait | None = None, camera_person: google_nest_sdm.camera_traits.CameraPersonTrait | None = None, camera_sound: google_nest_sdm.camera_traits.CameraSoundTrait | None = None, camera_clip_preview: google_nest_sdm.camera_traits.CameraClipPreviewTrait | None = None, doorbell_chime: google_nest_sdm.doorbell_traits.DoorbellChimeTrait | None = None, name: str = <factory>, type: str | None = None, relations: list[ParentRelation] = <factory>)
name: str

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]

Represents the parent structure or room of the device.

@staticmethod
def MakeDevice( raw_data: dict[str, typing.Any], auth: google_nest_sdm.auth.AbstractAuth) -> Device:
208    @staticmethod
209    def MakeDevice(raw_data: dict[str, Any], auth: AbstractAuth) -> Device:
210        """Create a device with the appropriate traits."""
211
212        # Hack for incorrect nest API response values
213        if (type := raw_data.get("type")) and type == "sdm.devices.types.DOORBELL":
214            if TRAITS not in raw_data:
215                raw_data[TRAITS] = {}
216            raw_data[TRAITS][DoorbellChimeTrait.NAME] = {}
217
218        device: Device = Device.parse_trait_object(raw_data)
219        device._auth = auth
220        device._diagnostics = Diagnostics()
221        cmd = Command(raw_data["name"], auth, device._diagnostics.subkey("command"))
222        for trait in device.traits.values():
223            if hasattr(trait, "_cmd"):
224                trait._cmd = cmd
225
226        event_traits = {
227            trait.EVENT_NAME
228            for trait in device.traits.values()
229            if hasattr(trait, "EVENT_NAME")
230        }
231        device._event_media_manager = EventMediaManager(
232            device.name or "",
233            device.traits,
234            event_traits,
235            diagnostics=device._diagnostics.subkey("event_media"),
236        )
237        return device

Create a device with the appropriate traits.

def add_update_listener(self, target: Callable[[], NoneType]) -> Callable[[], NoneType]:
239    def add_update_listener(self, target: Callable[[], None]) -> Callable[[], None]:
240        """Register a simple event listener notified on updates.
241
242        This will not block on media being fetched. To wait for media, use
243        the callback form the `EventMediaManager`.
244
245        The return value is a callable that will unregister the callback.
246        """
247
248        async def handle_event(event_message: EventMessage) -> None:
249            target()
250
251        return self.add_event_callback(handle_event)

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.

def add_event_callback( self, target: Callable[[google_nest_sdm.event.EventMessage], Awaitable[NoneType]]) -> Callable[[], NoneType]:
253    def add_event_callback(
254        self, target: Callable[[EventMessage], Awaitable[None]]
255    ) -> Callable[[], None]:
256        """Register an event callback for updates to this device.
257
258        This will not block on media being fetched. To wait for media, use
259        the callback form the `EventMediaManager`.
260
261        The return value is a callable that will unregister the callback.
262        """
263        self._callbacks.append(target)
264
265        def remove_callback() -> None:
266            """Remove the event_callback."""
267            self._callbacks.remove(target)
268
269        return remove_callback

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.

async def async_handle_event(self, event_message: google_nest_sdm.event.EventMessage) -> None:
271    async def async_handle_event(self, event_message: EventMessage) -> None:
272        """Process an event from the pubsub subscriber.
273
274        This will invoke any directly registered callbacks (before fetching media)
275        as well as any callbacks registered with the event media manager that
276        fire post-media.
277        """
278        _LOGGER.debug(
279            "Processing update %s @ %s", event_message.event_id, event_message.timestamp
280        )
281        if not event_message.resource_update_name:
282            raise EventProcessingError("Event was not resource update event")
283        if self.name != event_message.resource_update_name:
284            raise EventProcessingError(
285                f"Mismatch {self.name} != {event_message.resource_update_name}"
286            )
287        self._async_handle_traits(event_message)
288        for callback in self._callbacks:
289            await callback(event_message)
290        await self._event_media_manager.async_handle_events(event_message)

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.

def merge_from_update(self, new_device: Device) -> None:
329    def merge_from_update(self, new_device: Device) -> None:
330        """Merge fields from an updated device object.
331
332        This is used when refreshing the device list from the API.
333        """
334        self._async_update_traits(new_device, datetime.datetime.now(datetime.UTC))

Merge fields from an updated device object.

This is used when refreshing the device list from the API.

event_media_manager: google_nest_sdm.event_media.EventMediaManager
344    @property
345    def event_media_manager(self) -> EventMediaManager:
346        return self._event_media_manager
parent_relations: dict
348    @property
349    def parent_relations(self) -> dict:
350        """Room or structure for the device."""
351        return {relation.parent: relation.display_name for relation in self.relations}

Room or structure for the device.

def delete_relation(self, parent: str) -> None:
353    def delete_relation(self, parent: str) -> None:
354        """Remove a device relationship with the parent."""
355        self.relations = [
356            relation for relation in self.relations if relation.parent != parent
357        ]

Remove a device relationship with the parent.

def create_relation(self, relation: ParentRelation) -> None:
359    def create_relation(self, relation: ParentRelation) -> None:
360        """Add a new device relation."""
361        self.relations.append(relation)

Add a new device relation.

def get_diagnostics(self) -> dict[str, typing.Any]:
363    def get_diagnostics(self) -> dict[str, Any]:
364        return {
365            "data": redact_data(self.raw_data),
366            **self._diagnostics.as_dict(),
367        }
def to_dict(self, *, omit_none=False, by_alias=True):

The type of the None singleton.

def from_dict(cls, d, *, dialect=None):

The type of the None singleton.

class Device.Config(google_nest_sdm.model.TraitDataClass.Config):
369    class Config(TraitTypes.Config):
370        serialization_strategy = {
371            list[ParentRelation]: ParentRelationsSerializationStrategy(),
372        }
serialization_strategy = {list[ParentRelation]: <ParentRelationsSerializationStrategy object>}