Add nest SDM API camera/doorbell events (#42700)
* Add nest SDM API camera/doorbell events Events are fired when pubsub messages are received. When messages are received lookup a home assistant device id from the nest device id, so that the home assistant device id can be included in the event payload. * Update homeassistant/components/nest/__init__.py Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io> * Update nest code style based on PR feedback Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>pull/43619/head
parent
7f9a7791bf
commit
745823dd55
|
@ -5,7 +5,14 @@ from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from google_nest_sdm.event import AsyncEventCallback, EventMessage
|
from google_nest_sdm.event import (
|
||||||
|
AsyncEventCallback,
|
||||||
|
CameraMotionEvent,
|
||||||
|
CameraPersonEvent,
|
||||||
|
CameraSoundEvent,
|
||||||
|
DoorbellChimeEvent,
|
||||||
|
EventMessage,
|
||||||
|
)
|
||||||
from google_nest_sdm.exceptions import GoogleNestException
|
from google_nest_sdm.exceptions import GoogleNestException
|
||||||
from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
|
from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
|
||||||
from nest import Nest
|
from nest import Nest
|
||||||
|
@ -54,6 +61,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF_PROJECT_ID = "project_id"
|
CONF_PROJECT_ID = "project_id"
|
||||||
CONF_SUBSCRIBER_ID = "subscriber_id"
|
CONF_SUBSCRIBER_ID = "subscriber_id"
|
||||||
|
NEST_EVENT = "nest_event"
|
||||||
|
EVENT_TRAIT_MAP = {
|
||||||
|
DoorbellChimeEvent.NAME: "DoorbellChime",
|
||||||
|
CameraMotionEvent.NAME: "CameraMotion",
|
||||||
|
CameraPersonEvent.NAME: "CameraPerson",
|
||||||
|
CameraSoundEvent.NAME: "CameraSound",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Configuration for the legacy nest API
|
# Configuration for the legacy nest API
|
||||||
SERVICE_CANCEL_ETA = "cancel_eta"
|
SERVICE_CANCEL_ETA = "cancel_eta"
|
||||||
|
@ -169,21 +184,35 @@ class SignalUpdateCallback(AsyncEventCallback):
|
||||||
|
|
||||||
async def async_handle_event(self, event_message: EventMessage):
|
async def async_handle_event(self, event_message: EventMessage):
|
||||||
"""Process an incoming EventMessage."""
|
"""Process an incoming EventMessage."""
|
||||||
_LOGGER.debug("Update %s @ %s", event_message.event_id, event_message.timestamp)
|
if not event_message.resource_update_name:
|
||||||
|
_LOGGER.debug("Ignoring event with no device_id")
|
||||||
|
return
|
||||||
|
device_id = event_message.resource_update_name
|
||||||
|
_LOGGER.debug("Update for %s @ %s", device_id, event_message.timestamp)
|
||||||
traits = event_message.resource_update_traits
|
traits = event_message.resource_update_traits
|
||||||
if traits:
|
if traits:
|
||||||
_LOGGER.debug("Trait update %s", traits.keys())
|
_LOGGER.debug("Trait update %s", traits.keys())
|
||||||
|
# This event triggered an update to a device that changed some
|
||||||
|
# properties which the DeviceManager should already have received.
|
||||||
|
# Send a signal to refresh state of all listening devices.
|
||||||
|
async_dispatcher_send(self._hass, SIGNAL_NEST_UPDATE)
|
||||||
events = event_message.resource_update_events
|
events = event_message.resource_update_events
|
||||||
if events:
|
if not events:
|
||||||
_LOGGER.debug("Event Update %s", events.keys())
|
|
||||||
|
|
||||||
if not event_message.resource_update_traits:
|
|
||||||
# Note: Currently ignoring events like camera motion
|
|
||||||
return
|
return
|
||||||
# This event triggered an update to a device that changed some
|
_LOGGER.debug("Event Update %s", events.keys())
|
||||||
# properties which the DeviceManager should already have received.
|
device_registry = await self._hass.helpers.device_registry.async_get_registry()
|
||||||
# Send a signal to refresh state of all listening devices.
|
device_entry = device_registry.async_get_device({(DOMAIN, device_id)}, ())
|
||||||
async_dispatcher_send(self._hass, SIGNAL_NEST_UPDATE)
|
if not device_entry:
|
||||||
|
_LOGGER.debug("Ignoring event for unregistered device '%s'", device_id)
|
||||||
|
return
|
||||||
|
for event in events:
|
||||||
|
if event not in EVENT_TRAIT_MAP:
|
||||||
|
continue
|
||||||
|
message = {
|
||||||
|
"device_id": device_entry.id,
|
||||||
|
"type": EVENT_TRAIT_MAP[event],
|
||||||
|
}
|
||||||
|
self._hass.bus.async_fire(NEST_EVENT, message)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
|
|
@ -45,8 +45,6 @@ async def async_setup_sdm_entry(
|
||||||
_LOGGER.warning("Failed to get devices: %s", err)
|
_LOGGER.warning("Failed to get devices: %s", err)
|
||||||
raise PlatformNotReady from err
|
raise PlatformNotReady from err
|
||||||
|
|
||||||
# Fetch initial data so we have data when entities subscribe.
|
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
for device in device_manager.devices.values():
|
for device in device_manager.devices.values():
|
||||||
if TemperatureTrait.NAME in device.traits:
|
if TemperatureTrait.NAME in device.traits:
|
||||||
|
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""Test for Nest binary sensor platform for the Smart Device Management API.
|
||||||
|
|
||||||
|
These tests fake out the subscriber/devicemanager, and are not using a real
|
||||||
|
pubsub subscriber.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from google_nest_sdm.device import Device
|
||||||
|
from google_nest_sdm.event import EventMessage
|
||||||
|
|
||||||
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
|
from .common import async_setup_sdm_platform
|
||||||
|
|
||||||
|
from tests.common import async_capture_events
|
||||||
|
|
||||||
|
DOMAIN = "nest"
|
||||||
|
DEVICE_ID = "some-device-id"
|
||||||
|
PLATFORM = "camera"
|
||||||
|
NEST_EVENT = "nest_event"
|
||||||
|
EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..."
|
||||||
|
EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..."
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_devices(hass, device_type, traits={}):
|
||||||
|
"""Set up the platform and prerequisites."""
|
||||||
|
devices = {
|
||||||
|
DEVICE_ID: Device.MakeDevice(
|
||||||
|
{
|
||||||
|
"name": DEVICE_ID,
|
||||||
|
"type": device_type,
|
||||||
|
"traits": traits,
|
||||||
|
},
|
||||||
|
auth=None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return await async_setup_sdm_platform(hass, PLATFORM, devices=devices)
|
||||||
|
|
||||||
|
|
||||||
|
def create_device_traits(event_trait):
|
||||||
|
"""Create fake traits for a device."""
|
||||||
|
return {
|
||||||
|
"sdm.devices.traits.Info": {
|
||||||
|
"customName": "Front",
|
||||||
|
},
|
||||||
|
event_trait: {},
|
||||||
|
"sdm.devices.traits.CameraLiveStream": {
|
||||||
|
"maxVideoResolution": {
|
||||||
|
"width": 640,
|
||||||
|
"height": 480,
|
||||||
|
},
|
||||||
|
"videoCodecs": ["H264"],
|
||||||
|
"audioCodecs": ["AAC"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_event(event_type, device_id=DEVICE_ID):
|
||||||
|
"""Create an EventMessage for a single event type."""
|
||||||
|
events = {
|
||||||
|
event_type: {
|
||||||
|
"eventSessionId": EVENT_SESSION_ID,
|
||||||
|
"eventId": EVENT_ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return create_events(events=events, device_id=device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_events(events, device_id=DEVICE_ID):
|
||||||
|
"""Create an EventMessage for events."""
|
||||||
|
return EventMessage(
|
||||||
|
{
|
||||||
|
"eventId": "some-event-id",
|
||||||
|
"timestamp": utcnow().isoformat(timespec="seconds"),
|
||||||
|
"resourceUpdate": {
|
||||||
|
"name": device_id,
|
||||||
|
"events": events,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_doorbell_chime_event(hass):
|
||||||
|
"""Test a pubsub message for a doorbell event."""
|
||||||
|
events = async_capture_events(hass, NEST_EVENT)
|
||||||
|
subscriber = await async_setup_devices(
|
||||||
|
hass,
|
||||||
|
"sdm.devices.types.DOORBELL",
|
||||||
|
create_device_traits("sdm.devices.traits.DoorbellChime"),
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
entry = registry.async_get("camera.front")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.unique_id == "some-device-id-camera"
|
||||||
|
assert entry.original_name == "Front"
|
||||||
|
assert entry.domain == "camera"
|
||||||
|
|
||||||
|
device_registry = await hass.helpers.device_registry.async_get_registry()
|
||||||
|
device = device_registry.async_get(entry.device_id)
|
||||||
|
assert device.name == "Front"
|
||||||
|
assert device.model == "Doorbell"
|
||||||
|
assert device.identifiers == {("nest", DEVICE_ID)}
|
||||||
|
|
||||||
|
await subscriber.async_receive_event(
|
||||||
|
create_event("sdm.devices.events.DoorbellChime.Chime")
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].data == {
|
||||||
|
"device_id": entry.device_id,
|
||||||
|
"type": "DoorbellChime",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_motion_event(hass):
|
||||||
|
"""Test a pubsub message for a camera motion event."""
|
||||||
|
events = async_capture_events(hass, NEST_EVENT)
|
||||||
|
subscriber = await async_setup_devices(
|
||||||
|
hass,
|
||||||
|
"sdm.devices.types.CAMERA",
|
||||||
|
create_device_traits("sdm.devices.traits.CameraMotion"),
|
||||||
|
)
|
||||||
|
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
entry = registry.async_get("camera.front")
|
||||||
|
assert entry is not None
|
||||||
|
|
||||||
|
await subscriber.async_receive_event(
|
||||||
|
create_event("sdm.devices.events.CameraMotion.Motion")
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].data == {
|
||||||
|
"device_id": entry.device_id,
|
||||||
|
"type": "CameraMotion",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_sound_event(hass):
|
||||||
|
"""Test a pubsub message for a camera sound event."""
|
||||||
|
events = async_capture_events(hass, NEST_EVENT)
|
||||||
|
subscriber = await async_setup_devices(
|
||||||
|
hass,
|
||||||
|
"sdm.devices.types.CAMERA",
|
||||||
|
create_device_traits("sdm.devices.traits.CameraSound"),
|
||||||
|
)
|
||||||
|
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
entry = registry.async_get("camera.front")
|
||||||
|
assert entry is not None
|
||||||
|
|
||||||
|
await subscriber.async_receive_event(
|
||||||
|
create_event("sdm.devices.events.CameraSound.Sound")
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].data == {
|
||||||
|
"device_id": entry.device_id,
|
||||||
|
"type": "CameraSound",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_person_event(hass):
|
||||||
|
"""Test a pubsub message for a camera person event."""
|
||||||
|
events = async_capture_events(hass, NEST_EVENT)
|
||||||
|
subscriber = await async_setup_devices(
|
||||||
|
hass,
|
||||||
|
"sdm.devices.types.DOORBELL",
|
||||||
|
create_device_traits("sdm.devices.traits.CameraEventImage"),
|
||||||
|
)
|
||||||
|
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
entry = registry.async_get("camera.front")
|
||||||
|
assert entry is not None
|
||||||
|
|
||||||
|
await subscriber.async_receive_event(
|
||||||
|
create_event("sdm.devices.events.CameraPerson.Person")
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].data == {
|
||||||
|
"device_id": entry.device_id,
|
||||||
|
"type": "CameraPerson",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_multiple_event(hass):
|
||||||
|
"""Test a pubsub message for a camera person event."""
|
||||||
|
events = async_capture_events(hass, NEST_EVENT)
|
||||||
|
subscriber = await async_setup_devices(
|
||||||
|
hass,
|
||||||
|
"sdm.devices.types.DOORBELL",
|
||||||
|
create_device_traits("sdm.devices.traits.CameraEventImage"),
|
||||||
|
)
|
||||||
|
registry = await hass.helpers.entity_registry.async_get_registry()
|
||||||
|
entry = registry.async_get("camera.front")
|
||||||
|
assert entry is not None
|
||||||
|
|
||||||
|
event_map = {
|
||||||
|
"sdm.devices.events.CameraMotion.Motion": {
|
||||||
|
"eventSessionId": EVENT_SESSION_ID,
|
||||||
|
"eventId": EVENT_ID,
|
||||||
|
},
|
||||||
|
"sdm.devices.events.CameraPerson.Person": {
|
||||||
|
"eventSessionId": EVENT_SESSION_ID,
|
||||||
|
"eventId": EVENT_ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriber.async_receive_event(create_events(event_map))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(events) == 2
|
||||||
|
assert events[0].data == {
|
||||||
|
"device_id": entry.device_id,
|
||||||
|
"type": "CameraMotion",
|
||||||
|
}
|
||||||
|
assert events[1].data == {
|
||||||
|
"device_id": entry.device_id,
|
||||||
|
"type": "CameraPerson",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_event(hass):
|
||||||
|
"""Test a pubsub message for an unknown event type."""
|
||||||
|
events = async_capture_events(hass, NEST_EVENT)
|
||||||
|
subscriber = await async_setup_devices(
|
||||||
|
hass,
|
||||||
|
"sdm.devices.types.DOORBELL",
|
||||||
|
create_device_traits("sdm.devices.traits.DoorbellChime"),
|
||||||
|
)
|
||||||
|
await subscriber.async_receive_event(create_event("some-event-id"))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(events) == 0
|
Loading…
Reference in New Issue