Add nest device triggers for camera and doorbell events (#43548)

This commit is contained in:
Allen Porter 2020-11-30 00:19:42 -08:00 committed by GitHub
parent 2cbb93be43
commit 945a0a9f7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 520 additions and 36 deletions

View File

@ -572,7 +572,18 @@ omit =
homeassistant/components/neato/vacuum.py
homeassistant/components/nederlandse_spoorwegen/sensor.py
homeassistant/components/nello/lock.py
homeassistant/components/nest/*
homeassistant/components/nest/__init__.py
homeassistant/components/nest/api.py
homeassistant/components/nest/binary_sensor.py
homeassistant/components/nest/camera.py
homeassistant/components/nest/camera_legacy.py
homeassistant/components/nest/camera_sdm.py
homeassistant/components/nest/climate.py
homeassistant/components/nest/climate_legacy.py
homeassistant/components/nest/climate_sdm.py
homeassistant/components/nest/local_auth.py
homeassistant/components/nest/sensor.py
homeassistant/components/nest/sensor_legacy.py
homeassistant/components/netatmo/__init__.py
homeassistant/components/netatmo/api.py
homeassistant/components/netatmo/camera.py

View File

@ -5,14 +5,7 @@ from datetime import datetime, timedelta
import logging
import threading
from google_nest_sdm.event import (
AsyncEventCallback,
CameraMotionEvent,
CameraPersonEvent,
CameraSoundEvent,
DoorbellChimeEvent,
EventMessage,
)
from google_nest_sdm.event import AsyncEventCallback, EventMessage
from google_nest_sdm.exceptions import GoogleNestException
from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
from nest import Nest
@ -50,24 +43,19 @@ from . import api, config_flow, local_auth
from .const import (
API_URL,
DATA_SDM,
DATA_SUBSCRIBER,
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
SIGNAL_NEST_UPDATE,
)
from .events import EVENT_NAME_MAP, NEST_EVENT
_CONFIGURING = {}
_LOGGER = logging.getLogger(__name__)
CONF_PROJECT_ID = "project_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
@ -206,11 +194,12 @@ class SignalUpdateCallback(AsyncEventCallback):
_LOGGER.debug("Ignoring event for unregistered device '%s'", device_id)
return
for event in events:
if event not in EVENT_TRAIT_MAP:
event_type = EVENT_NAME_MAP.get(event)
if not event_type:
continue
message = {
"device_id": device_entry.id,
"type": EVENT_TRAIT_MAP[event],
"type": event_type,
}
self._hass.bus.async_fire(NEST_EVENT, message)
@ -254,7 +243,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
subscriber.stop_async()
raise ConfigEntryNotReady from err
hass.data[DOMAIN][entry.entry_id] = subscriber
hass.data[DOMAIN][DATA_SUBSCRIBER] = subscriber
for component in PLATFORMS:
hass.async_create_task(
@ -270,7 +259,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
# Legacy API
return True
subscriber = hass.data[DOMAIN][entry.entry_id]
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
subscriber.stop_async()
unload_ok = all(
await asyncio.gather(
@ -281,7 +270,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
hass.data[DOMAIN].pop(DATA_SUBSCRIBER)
return unload_ok

View File

@ -18,7 +18,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.dt import utcnow
from .const import DOMAIN, SIGNAL_NEST_UPDATE
from .const import DATA_SUBSCRIBER, DOMAIN, SIGNAL_NEST_UPDATE
from .device_info import DeviceInfo
_LOGGER = logging.getLogger(__name__)
@ -32,7 +32,7 @@ async def async_setup_sdm_entry(
) -> None:
"""Set up the cameras."""
subscriber = hass.data[DOMAIN][entry.entry_id]
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
try:
device_manager = await subscriber.async_get_device_manager()
except GoogleNestException as err:

View File

@ -39,7 +39,7 @@ from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType
from .const import DOMAIN, SIGNAL_NEST_UPDATE
from .const import DATA_SUBSCRIBER, DOMAIN, SIGNAL_NEST_UPDATE
from .device_info import DeviceInfo
# Mapping for sdm.devices.traits.ThermostatMode mode field
@ -81,7 +81,7 @@ async def async_setup_sdm_entry(
) -> None:
"""Set up the client entities."""
subscriber = hass.data[DOMAIN][entry.entry_id]
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
try:
device_manager = await subscriber.async_get_device_manager()
except GoogleNestException as err:

View File

@ -2,6 +2,7 @@
DOMAIN = "nest"
DATA_SDM = "sdm"
DATA_SUBSCRIBER = "subscriber"
SIGNAL_NEST_UPDATE = "nest_update"

View File

@ -0,0 +1,101 @@
"""Provides device automations for Nest."""
import logging
from typing import List
import voluptuous as vol
from homeassistant.components.automation import AutomationActionType
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
from homeassistant.components.homeassistant.triggers import event as event_trigger
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .const import DATA_SUBSCRIBER, DOMAIN
from .events import DEVICE_TRAIT_TRIGGER_MAP, NEST_EVENT
_LOGGER = logging.getLogger(__name__)
DEVICE = "device"
TRIGGER_TYPES = set(DEVICE_TRAIT_TRIGGER_MAP.values())
TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
}
)
async def async_get_nest_device_id(hass: HomeAssistant, device_id: str) -> str:
"""Get the nest API device_id from the HomeAssistant device_id."""
device_registry = await hass.helpers.device_registry.async_get_registry()
device = device_registry.async_get(device_id)
for (domain, unique_id) in device.identifiers:
if domain == DOMAIN:
return unique_id
return None
async def async_get_device_trigger_types(
hass: HomeAssistant, nest_device_id: str
) -> List[str]:
"""List event triggers supported for a Nest device."""
# All devices should have already been loaded so any failures here are
# "shouldn't happen" cases
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
device_manager = await subscriber.async_get_device_manager()
nest_device = device_manager.devices.get(nest_device_id)
if not nest_device:
raise InvalidDeviceAutomationConfig(f"Nest device not found {nest_device_id}")
# Determine the set of event types based on the supported device traits
trigger_types = []
for trait in nest_device.traits.keys():
trigger_type = DEVICE_TRAIT_TRIGGER_MAP.get(trait)
if trigger_type:
trigger_types.append(trigger_type)
return trigger_types
async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]:
"""List device triggers for a Nest device."""
nest_device_id = await async_get_nest_device_id(hass, device_id)
if not nest_device_id:
raise InvalidDeviceAutomationConfig(f"Device not found {device_id}")
trigger_types = await async_get_device_trigger_types(hass, nest_device_id)
return [
{
CONF_PLATFORM: DEVICE,
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: trigger_type,
}
for trigger_type in trigger_types
]
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: AutomationActionType,
automation_info: dict,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
config = TRIGGER_SCHEMA(config)
event_config = event_trigger.TRIGGER_SCHEMA(
{
event_trigger.CONF_PLATFORM: "event",
event_trigger.CONF_EVENT_TYPE: NEST_EVENT,
event_trigger.CONF_EVENT_DATA: {
CONF_DEVICE_ID: config[CONF_DEVICE_ID],
CONF_TYPE: config[CONF_TYPE],
},
}
)
return await event_trigger.async_attach_trigger(
hass, event_config, action, automation_info, platform_type="device"
)

View File

@ -0,0 +1,49 @@
"""Library from Pub/sub messages, events and device triggers."""
from google_nest_sdm.camera_traits import (
CameraMotionTrait,
CameraPersonTrait,
CameraSoundTrait,
)
from google_nest_sdm.doorbell_traits import DoorbellChimeTrait
from google_nest_sdm.event import (
CameraMotionEvent,
CameraPersonEvent,
CameraSoundEvent,
DoorbellChimeEvent,
)
NEST_EVENT = "nest_event"
# The nest_event namespace will fire events that are triggered from messages
# received via the Pub/Sub subscriber.
#
# An example event data payload:
# {
# "device_id": "enterprises/some/device/identifier"
# "event_type": "camera_motion"
# }
#
# The following event types are fired:
EVENT_DOORBELL_CHIME = "doorbell_chime"
EVENT_CAMERA_MOTION = "camera_motion"
EVENT_CAMERA_PERSON = "camera_person"
EVENT_CAMERA_SOUND = "camera_sound"
# Mapping of supported device traits to home assistant event types. Devices
# that support these traits will generate Pub/Sub event messages in
# the EVENT_NAME_MAP
DEVICE_TRAIT_TRIGGER_MAP = {
DoorbellChimeTrait.NAME: EVENT_DOORBELL_CHIME,
CameraMotionTrait.NAME: EVENT_CAMERA_MOTION,
CameraPersonTrait.NAME: EVENT_CAMERA_PERSON,
CameraSoundTrait.NAME: EVENT_CAMERA_SOUND,
}
# Mapping of incoming SDM Pub/Sub event message types to the home assistant
# event type to fire.
EVENT_NAME_MAP = {
DoorbellChimeEvent.NAME: EVENT_DOORBELL_CHIME,
CameraMotionEvent.NAME: EVENT_CAMERA_MOTION,
CameraPersonEvent.NAME: EVENT_CAMERA_PERSON,
CameraSoundEvent.NAME: EVENT_CAMERA_SOUND,
}

View File

@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from .const import DOMAIN, SIGNAL_NEST_UPDATE
from .const import DATA_SUBSCRIBER, DOMAIN, SIGNAL_NEST_UPDATE
from .device_info import DeviceInfo
_LOGGER = logging.getLogger(__name__)
@ -38,7 +38,7 @@ async def async_setup_sdm_entry(
) -> None:
"""Set up the sensors."""
subscriber = hass.data[DOMAIN][entry.entry_id]
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
try:
device_manager = await subscriber.async_get_device_manager()
except GoogleNestException as err:

View File

@ -7,12 +7,16 @@
"init": {
"title": "Authentication Provider",
"description": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
"data": { "flow_impl": "Provider" }
"data": {
"flow_impl": "Provider"
}
},
"link": {
"title": "Link Nest Account",
"description": "To link your Nest account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided pin code below.",
"data": { "code": "[%key:common::config_flow::data::pin%]" }
"data": {
"code": "[%key:common::config_flow::data::pin%]"
}
}
},
"error": {
@ -31,5 +35,13 @@
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"device_automation": {
"trigger_type": {
"camera_person": "Person detected",
"camera_motion": "Motion detected",
"camera_sound": "Sound detected",
"doorbell_chime": "Doorbell pressed"
}
}
}

View File

@ -36,5 +36,13 @@
"title": "Pick Authentication Method"
}
}
},
"device_automation": {
"trigger_type": {
"camera_person": "Person detected",
"camera_motion": "Motion detected",
"camera_sound": "Sound detected",
"doorbell_chime": "Doorbell pressed"
}
}
}
}

View File

@ -0,0 +1,313 @@
"""The tests for Nest device triggers."""
from google_nest_sdm.device import Device
from google_nest_sdm.event import EventMessage
import pytest
import homeassistant.components.automation as automation
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
from homeassistant.components.nest import DOMAIN, NEST_EVENT
from homeassistant.setup import async_setup_component
from .common import async_setup_sdm_platform
from tests.common import (
assert_lists_same,
async_get_device_automations,
async_mock_service,
)
DEVICE_ID = "some-device-id"
DEVICE_NAME = "My Camera"
DATA_MESSAGE = {"message": "service-called"}
def make_camera(device_id, name=DEVICE_NAME, traits={}):
"""Create a nest camera."""
traits = traits.copy()
traits.update(
{
"sdm.devices.traits.Info": {
"customName": name,
},
"sdm.devices.traits.CameraLiveStream": {
"maxVideoResolution": {
"width": 640,
"height": 480,
},
"videoCodecs": ["H264"],
"audioCodecs": ["AAC"],
},
}
)
return Device.MakeDevice(
{
"name": device_id,
"type": "sdm.devices.types.CAMERA",
"traits": traits,
},
auth=None,
)
async def async_setup_camera(hass, devices=None):
"""Set up the platform and prerequisites for testing available triggers."""
if not devices:
devices = {DEVICE_ID: make_camera(device_id=DEVICE_ID)}
return await async_setup_sdm_platform(hass, "camera", devices)
async def setup_automation(hass, device_id, trigger_type):
"""Set up an automation trigger for testing triggering."""
return await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
"platform": "device",
"domain": DOMAIN,
"device_id": device_id,
"type": trigger_type,
},
"action": {
"service": "test.automation",
"data": DATA_MESSAGE,
},
},
]
},
)
@pytest.fixture
def calls(hass):
"""Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
async def test_get_triggers(hass):
"""Test we get the expected triggers from a nest."""
camera = make_camera(
device_id=DEVICE_ID,
traits={
"sdm.devices.traits.CameraMotion": {},
"sdm.devices.traits.CameraPerson": {},
},
)
await async_setup_camera(hass, {DEVICE_ID: camera})
device_registry = await hass.helpers.device_registry.async_get_registry()
device_entry = device_registry.async_get_device(
{("nest", DEVICE_ID)}, connections={}
)
expected_triggers = [
{
"platform": "device",
"domain": DOMAIN,
"type": "camera_motion",
"device_id": device_entry.id,
},
{
"platform": "device",
"domain": DOMAIN,
"type": "camera_person",
"device_id": device_entry.id,
},
]
triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
assert_lists_same(triggers, expected_triggers)
async def test_multiple_devices(hass):
"""Test we get the expected triggers from a nest."""
camera1 = make_camera(
device_id="device-id-1",
name="Camera 1",
traits={
"sdm.devices.traits.CameraSound": {},
},
)
camera2 = make_camera(
device_id="device-id-2",
name="Camera 2",
traits={
"sdm.devices.traits.DoorbellChime": {},
},
)
await async_setup_camera(hass, {"device-id-1": camera1, "device-id-2": camera2})
registry = await hass.helpers.entity_registry.async_get_registry()
entry1 = registry.async_get("camera.camera_1")
assert entry1.unique_id == "device-id-1-camera"
entry2 = registry.async_get("camera.camera_2")
assert entry2.unique_id == "device-id-2-camera"
triggers = await async_get_device_automations(hass, "trigger", entry1.device_id)
assert len(triggers) == 1
assert {
"platform": "device",
"domain": DOMAIN,
"type": "camera_sound",
"device_id": entry1.device_id,
} == triggers[0]
triggers = await async_get_device_automations(hass, "trigger", entry2.device_id)
assert len(triggers) == 1
assert {
"platform": "device",
"domain": DOMAIN,
"type": "doorbell_chime",
"device_id": entry2.device_id,
} == triggers[0]
async def test_triggers_for_invalid_device_id(hass):
"""Get triggers for a device not found in the API."""
camera = make_camera(
device_id=DEVICE_ID,
traits={
"sdm.devices.traits.CameraMotion": {},
"sdm.devices.traits.CameraPerson": {},
},
)
await async_setup_camera(hass, {DEVICE_ID: camera})
device_registry = await hass.helpers.device_registry.async_get_registry()
device_entry = device_registry.async_get_device(
{("nest", DEVICE_ID)}, connections={}
)
assert device_entry is not None
# Create an additional device that does not exist. Fetching supported
# triggers for an unknown device will fail.
assert len(device_entry.config_entries) == 1
config_entry_id = next(iter(device_entry.config_entries))
device_entry_2 = device_registry.async_get_or_create(
config_entry_id=config_entry_id, identifiers={(DOMAIN, "some-unknown-nest-id")}
)
assert device_entry_2 is not None
with pytest.raises(InvalidDeviceAutomationConfig):
await async_get_device_automations(hass, "trigger", device_entry_2.id)
async def test_no_triggers(hass):
"""Test we get the expected triggers from a nest."""
camera = make_camera(device_id=DEVICE_ID, traits={})
await async_setup_camera(hass, {DEVICE_ID: camera})
registry = await hass.helpers.entity_registry.async_get_registry()
entry = registry.async_get("camera.my_camera")
assert entry.unique_id == "some-device-id-camera"
triggers = await async_get_device_automations(hass, "trigger", entry.device_id)
assert [] == triggers
async def test_fires_on_camera_motion(hass, calls):
"""Test camera_motion triggers firing."""
assert await setup_automation(hass, DEVICE_ID, "camera_motion")
message = {"device_id": DEVICE_ID, "type": "camera_motion"}
hass.bus.async_fire(NEST_EVENT, message)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data == DATA_MESSAGE
async def test_fires_on_camera_person(hass, calls):
"""Test camera_person triggers firing."""
assert await setup_automation(hass, DEVICE_ID, "camera_person")
message = {"device_id": DEVICE_ID, "type": "camera_person"}
hass.bus.async_fire(NEST_EVENT, message)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data == DATA_MESSAGE
async def test_fires_on_camera_sound(hass, calls):
"""Test camera_person triggers firing."""
assert await setup_automation(hass, DEVICE_ID, "camera_sound")
message = {"device_id": DEVICE_ID, "type": "camera_sound"}
hass.bus.async_fire(NEST_EVENT, message)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data == DATA_MESSAGE
async def test_fires_on_doorbell_chime(hass, calls):
"""Test doorbell_chime triggers firing."""
assert await setup_automation(hass, DEVICE_ID, "doorbell_chime")
message = {"device_id": DEVICE_ID, "type": "doorbell_chime"}
hass.bus.async_fire(NEST_EVENT, message)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data == DATA_MESSAGE
async def test_trigger_for_wrong_device_id(hass, calls):
"""Test for turn_on and turn_off triggers firing."""
assert await setup_automation(hass, DEVICE_ID, "camera_motion")
message = {"device_id": "wrong-device-id", "type": "camera_motion"}
hass.bus.async_fire(NEST_EVENT, message)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_trigger_for_wrong_event_type(hass, calls):
"""Test for turn_on and turn_off triggers firing."""
assert await setup_automation(hass, DEVICE_ID, "camera_motion")
message = {"device_id": DEVICE_ID, "type": "wrong-event-type"}
hass.bus.async_fire(NEST_EVENT, message)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_subscriber_automation(hass, calls):
"""Test end to end subscriber triggers automation."""
camera = make_camera(
device_id=DEVICE_ID,
traits={
"sdm.devices.traits.CameraMotion": {},
},
)
subscriber = await async_setup_camera(hass, {DEVICE_ID: camera})
device_registry = await hass.helpers.device_registry.async_get_registry()
device_entry = device_registry.async_get_device(
{("nest", DEVICE_ID)}, connections={}
)
assert await setup_automation(hass, device_entry.id, "camera_motion")
# Simulate a pubsub message received by the subscriber with a motion event
event = EventMessage(
{
"eventId": "some-event-id",
"timestamp": "2019-01-01T00:00:01Z",
"resourceUpdate": {
"name": DEVICE_ID,
"events": {
"sdm.devices.events.CameraMotion.Motion": {
"eventSessionId": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...",
"eventId": "FWWVQVUdGNUlTU2V4MGV2aTNXV...",
},
},
},
},
auth=None,
)
await subscriber.async_receive_event(event)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data == DATA_MESSAGE

View File

@ -110,7 +110,7 @@ async def test_doorbell_chime_event(hass):
assert len(events) == 1
assert events[0].data == {
"device_id": entry.device_id,
"type": "DoorbellChime",
"type": "doorbell_chime",
}
@ -134,7 +134,7 @@ async def test_camera_motion_event(hass):
assert len(events) == 1
assert events[0].data == {
"device_id": entry.device_id,
"type": "CameraMotion",
"type": "camera_motion",
}
@ -158,7 +158,7 @@ async def test_camera_sound_event(hass):
assert len(events) == 1
assert events[0].data == {
"device_id": entry.device_id,
"type": "CameraSound",
"type": "camera_sound",
}
@ -182,7 +182,7 @@ async def test_camera_person_event(hass):
assert len(events) == 1
assert events[0].data == {
"device_id": entry.device_id,
"type": "CameraPerson",
"type": "camera_person",
}
@ -215,11 +215,11 @@ async def test_camera_multiple_event(hass):
assert len(events) == 2
assert events[0].data == {
"device_id": entry.device_id,
"type": "CameraMotion",
"type": "camera_motion",
}
assert events[1].data == {
"device_id": entry.device_id,
"type": "CameraPerson",
"type": "camera_person",
}