diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index bdec44a3c8599d..5d3e9652039d0d 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -97,7 +97,7 @@ ) # Platforms for SDM API -PLATFORMS = [Platform.CAMERA, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.CAMERA, Platform.CLIMATE, Platform.EVENT, Platform.SENSOR] # Fetch media events with a disk backed cache, with a limit for each camera # device. The largest media items are mp4 clips at ~120kb each, and we target diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py new file mode 100644 index 00000000000000..d2897793270881 --- /dev/null +++ b/homeassistant/components/nest/event.py @@ -0,0 +1,124 @@ +"""Event platform for Google Nest.""" + +from dataclasses import dataclass +import logging + +from google_nest_sdm.device import Device +from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.event import EventMessage, EventType +from google_nest_sdm.traits import TraitType + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_DEVICE_MANAGER, DOMAIN +from .device_info import NestDeviceInfo +from .events import ( + EVENT_CAMERA_MOTION, + EVENT_CAMERA_PERSON, + EVENT_CAMERA_SOUND, + EVENT_DOORBELL_CHIME, + EVENT_NAME_MAP, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(kw_only=True, frozen=True) +class NestEventEntityDescription(EventEntityDescription): + """Entity description for nest event entities.""" + + trait_types: list[TraitType] + api_event_types: list[EventType] + event_types: list[str] + + +ENTITY_DESCRIPTIONS = [ + NestEventEntityDescription( + key=EVENT_DOORBELL_CHIME, + translation_key="chime", + device_class=EventDeviceClass.DOORBELL, + event_types=[EVENT_DOORBELL_CHIME], + trait_types=[TraitType.DOORBELL_CHIME], + api_event_types=[EventType.DOORBELL_CHIME], + ), + NestEventEntityDescription( + key=EVENT_CAMERA_MOTION, + translation_key="motion", + device_class=EventDeviceClass.MOTION, + event_types=[EVENT_CAMERA_MOTION, EVENT_CAMERA_PERSON, EVENT_CAMERA_SOUND], + trait_types=[ + TraitType.CAMERA_MOTION, + TraitType.CAMERA_PERSON, + TraitType.CAMERA_SOUND, + ], + api_event_types=[ + EventType.CAMERA_MOTION, + EventType.CAMERA_PERSON, + EventType.CAMERA_SOUND, + ], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the sensors.""" + + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] + async_add_entities( + NestTraitEventEntity(desc, device) + for device in device_manager.devices.values() + for desc in ENTITY_DESCRIPTIONS + if any(trait in device.traits for trait in desc.trait_types) + ) + + +class NestTraitEventEntity(EventEntity): + """Nest doorbell event entity.""" + + entity_description: NestEventEntityDescription + _attr_has_entity_name = True + + def __init__( + self, entity_description: NestEventEntityDescription, device: Device + ) -> None: + """Initialize the event entity.""" + self.entity_description = entity_description + self._device = device + self._attr_unique_id = f"{device.name}-{entity_description.key}" + self._attr_device_info = NestDeviceInfo(device).device_info + + async def _async_handle_event(self, event_message: EventMessage) -> None: + """Handle a device event.""" + if ( + event_message.relation_update + or not event_message.resource_update_name + or not (events := event_message.resource_update_events) + ): + return + for api_event_type, nest_event in events.items(): + if api_event_type not in self.entity_description.api_event_types: + continue + + event_type = EVENT_NAME_MAP[api_event_type] + + self._trigger_event( + event_type, + {"nest_event_id": nest_event.event_token}, + ) + self.async_write_ha_state() + return + + async def async_added_to_hass(self) -> None: + """Run when entity is added to attach an event listener.""" + self.async_on_remove(self._device.add_event_callback(self._async_handle_event)) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 35e1cc68165bfd..cd915acfbe5be9 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -72,5 +72,31 @@ "title": "Legacy Works With Nest has been removed", "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } + }, + "entity": { + "event": { + "chime": { + "name": "Chime", + "state_attributes": { + "event_type": { + "state": { + "doorbell_chime": "[%key:component::nest::entity::event::chime::name%]" + } + } + } + }, + "motion": { + "name": "[%key:component::event::entity_component::motion::name%]", + "state_attributes": { + "event_type": { + "state": { + "camera_motion": "[%key:component::event::entity_component::motion::name%]", + "camera_person": "Person", + "camera_sound": "Sound" + } + } + } + } + } } } diff --git a/tests/components/nest/test_event.py b/tests/components/nest/test_event.py new file mode 100644 index 00000000000000..531252285e00e0 --- /dev/null +++ b/tests/components/nest/test_event.py @@ -0,0 +1,193 @@ +"""Test for Nest event platform.""" + +from typing import Any + +from google_nest_sdm.event import EventMessage, EventType +from google_nest_sdm.traits import TraitType +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .common import DEVICE_ID, CreateDevice +from .conftest import FakeSubscriber, PlatformSetup + +EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." +EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." +ENCODED_EVENT_ID = "WyJDalk1WTNWS2FUWndSM280WTE5WWJUVmZNRi4uLiIsICJGV1dWUVZVZEdOVWxUVTJWNE1HVjJhVE5YVi4uLiJd" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture for platforms to setup.""" + return [Platform.EVENT] + + +@pytest.fixture +def device_type() -> str: + """Fixture for the type of device under test.""" + return "sdm.devices.types.DOORBELL" + + +@pytest.fixture +async def device_traits() -> dict[str, Any]: + """Fixture to set default device traits used when creating devices.""" + return { + "sdm.devices.traits.Info": { + "customName": "Front", + }, + "sdm.devices.traits.CameraLiveStream": { + "maxVideoResolution": { + "width": 640, + "height": 480, + }, + "videoCodecs": ["H264"], + "audioCodecs": ["AAC"], + }, + } + + +def create_events(events: str) -> EventMessage: + """Create an EventMessage for events.""" + return EventMessage.create_event( + { + "eventId": "some-event-id", + "timestamp": utcnow().isoformat(timespec="seconds"), + "resourceUpdate": { + "name": DEVICE_ID, + "events": { + event: { + "eventSessionId": EVENT_SESSION_ID, + "eventId": EVENT_ID, + } + for event in events + }, + }, + }, + auth=None, + ) + + +@pytest.mark.parametrize( + ( + "trait_types", + "entity_id", + "expected_attributes", + "api_event_type", + "expected_event_type", + ), + [ + ( + [TraitType.DOORBELL_CHIME, TraitType.CAMERA_MOTION], + "event.front_chime", + { + "device_class": "doorbell", + "event_types": ["doorbell_chime"], + "friendly_name": "Front Chime", + }, + EventType.DOORBELL_CHIME, + "doorbell_chime", + ), + ( + [TraitType.CAMERA_MOTION, TraitType.CAMERA_PERSON, TraitType.CAMERA_SOUND], + "event.front_motion", + { + "device_class": "motion", + "event_types": ["camera_motion", "camera_person", "camera_sound"], + "friendly_name": "Front Motion", + }, + EventType.CAMERA_MOTION, + "camera_motion", + ), + ( + [TraitType.CAMERA_MOTION, TraitType.CAMERA_PERSON, TraitType.CAMERA_SOUND], + "event.front_motion", + { + "device_class": "motion", + "event_types": ["camera_motion", "camera_person", "camera_sound"], + "friendly_name": "Front Motion", + }, + EventType.CAMERA_PERSON, + "camera_person", + ), + ( + [TraitType.CAMERA_MOTION, TraitType.CAMERA_PERSON, TraitType.CAMERA_SOUND], + "event.front_motion", + { + "device_class": "motion", + "event_types": ["camera_motion", "camera_person", "camera_sound"], + "friendly_name": "Front Motion", + }, + EventType.CAMERA_SOUND, + "camera_sound", + ), + ], +) +async def test_receive_events( + hass: HomeAssistant, + subscriber: FakeSubscriber, + setup_platform: PlatformSetup, + create_device: CreateDevice, + trait_types: list[TraitType], + entity_id: str, + expected_attributes: dict[str, str], + api_event_type: EventType, + expected_event_type: str, +) -> None: + """Test a pubsub message for a camera person event.""" + create_device.create( + raw_traits={ + **{trait_type: {} for trait_type in trait_types}, + api_event_type: {}, + } + ) + await setup_platform() + + state = hass.states.get(entity_id) + assert state.state == "unknown" + assert state.attributes == { + **expected_attributes, + "event_type": None, + } + + await subscriber.async_receive_event(create_events([api_event_type])) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state != "unknown" + assert state.attributes == { + **expected_attributes, + "event_type": expected_event_type, + "nest_event_id": ENCODED_EVENT_ID, + } + + +@pytest.mark.parametrize(("trait_type"), [(TraitType.DOORBELL_CHIME)]) +async def test_ignore_unrelated_event( + hass: HomeAssistant, + subscriber: FakeSubscriber, + setup_platform: PlatformSetup, + create_device: CreateDevice, + trait_type: TraitType, +) -> None: + """Test a pubsub message for a camera person event.""" + create_device.create( + raw_traits={ + trait_type: {}, + } + ) + await setup_platform() + + # Device does not have traits matching this event type + await subscriber.async_receive_event(create_events([EventType.CAMERA_MOTION])) + await hass.async_block_till_done() + + state = hass.states.get("event.front_chime") + assert state.state == "unknown" + assert state.attributes == { + "device_class": "doorbell", + "event_type": None, + "event_types": ["doorbell_chime"], + "friendly_name": "Front Chime", + }