Skip to content

Commit

Permalink
Add nest event platform (home-assistant#123042)
Browse files Browse the repository at this point in the history
* Add nest event platform

* Translate entities

* Put motion events into a single entity type

* Remove none types

* Set event entity descriptions as kw only

* Update translations for event entities

* Add single event entity per trait type

* Update translation keys
  • Loading branch information
allenporter authored Aug 24, 2024
1 parent 32f7559 commit 3e3d27f
Show file tree
Hide file tree
Showing 4 changed files with 344 additions and 1 deletion.
2 changes: 1 addition & 1 deletion homeassistant/components/nest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions homeassistant/components/nest/event.py
Original file line number Diff line number Diff line change
@@ -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))
26 changes: 26 additions & 0 deletions homeassistant/components/nest/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
}
}
193 changes: 193 additions & 0 deletions tests/components/nest/test_event.py
Original file line number Diff line number Diff line change
@@ -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",
}

0 comments on commit 3e3d27f

Please sign in to comment.