1
mirror of https://github.com/home-assistant/core synced 2024-08-28 03:36:46 +02:00

Add doorbell event to google_assistant (#97123)

* First attempt async_report_state_all

* Move notificationSupportedByAgent to SYNC response

* Make notificationSupportedByAgent conditional

* Add generic sync_options method

* Report event

* Add event_type as ID

* User UUID, imlement query_notifications

* Refactor query_notifications

* Add test

* MyPy

* Unreachable code

* Tweak

* Correct notification message

* Timestamp was wrong unit, it should be in seconds
* Can only allow doorbell class, since it's the only type

* Fix test

* Remove unrelated changes - improve coverage

* Additional tests

---------

Co-authored-by: Joakim Plate <elupus@ecce.se>
This commit is contained in:
Jan Bouwhuis 2023-09-25 23:20:02 +02:00 committed by GitHub
parent 6387263007
commit c5b32d6307
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 390 additions and 13 deletions

View File

@ -345,14 +345,16 @@ class CloudGoogleConfig(AbstractConfig):
assistant_options = settings.get(CLOUD_GOOGLE, {})
return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
async def async_report_state(self, message: Any, agent_user_id: str) -> None:
async def async_report_state(
self, message: Any, agent_user_id: str, event_id: str | None = None
) -> None:
"""Send a state report to Google."""
try:
await self._cloud.google_report_state.async_send_message(message)
except ErrorResponse as err:
_LOGGER.warning("Error reporting state - %s: %s", err.code, err.message)
async def _async_request_sync_devices(self, agent_user_id: str) -> int:
async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus | int:
"""Trigger a sync with Google."""
if self._sync_entities_lock.locked():
return HTTPStatus.OK

View File

@ -6,6 +6,7 @@ from homeassistant.components import (
camera,
climate,
cover,
event,
fan,
group,
humidifier,
@ -48,6 +49,7 @@ DEFAULT_EXPOSED_DOMAINS = [
"binary_sensor",
"climate",
"cover",
"event",
"fan",
"group",
"humidifier",
@ -73,6 +75,7 @@ TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA"
TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN"
TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER"
TYPE_DOOR = f"{PREFIX_TYPES}DOOR"
TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL"
TYPE_FAN = f"{PREFIX_TYPES}FAN"
TYPE_GARAGE = f"{PREFIX_TYPES}GARAGE"
TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER"
@ -162,6 +165,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
(cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE,
(cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER,
(cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW,
(event.DOMAIN, event.EventDeviceClass.DOORBELL): TYPE_DOORBELL,
(
humidifier.DOMAIN,
humidifier.HumidifierDeviceClass.DEHUMIDIFIER,

View File

@ -9,6 +9,7 @@ from functools import lru_cache
from http import HTTPStatus
import logging
import pprint
from typing import Any
from aiohttp.web import json_response
from awesomeversion import AwesomeVersion
@ -183,7 +184,9 @@ class AbstractConfig(ABC):
"""If an entity should have 2FA checked."""
return True
async def async_report_state(self, message, agent_user_id: str):
async def async_report_state(
self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None
) -> HTTPStatus | None:
"""Send a state report to Google."""
raise NotImplementedError
@ -234,6 +237,33 @@ class AbstractConfig(ABC):
)
return max(res, default=204)
async def async_sync_notification(
self, agent_user_id: str, event_id: str, payload: dict[str, Any]
) -> HTTPStatus:
"""Sync notification to Google."""
# Remove any pending sync
self._google_sync_unsub.pop(agent_user_id, lambda: None)()
status = await self.async_report_state(payload, agent_user_id, event_id)
assert status is not None
if status == HTTPStatus.NOT_FOUND:
await self.async_disconnect_agent_user(agent_user_id)
return status
async def async_sync_notification_all(
self, event_id: str, payload: dict[str, Any]
) -> HTTPStatus:
"""Sync notification to Google for all registered agents."""
if not self._store.agent_user_ids:
return HTTPStatus.NO_CONTENT
res = await gather(
*(
self.async_sync_notification(agent_user_id, event_id, payload)
for agent_user_id in self._store.agent_user_ids
)
)
return max(res, default=HTTPStatus.NO_CONTENT)
@callback
def async_schedule_google_sync(self, agent_user_id: str):
"""Schedule a sync."""
@ -617,7 +647,6 @@ class GoogleEntity:
state.domain, state.attributes.get(ATTR_DEVICE_CLASS)
),
}
# Add aliases
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or (
entity_entry and entity_entry.aliases
@ -639,6 +668,10 @@ class GoogleEntity:
for trt in traits:
device["attributes"].update(trt.sync_attributes())
# Add trait options
for trt in traits:
device.update(trt.sync_options())
# Add roomhint
if room := entity_config.get(CONF_ROOM_HINT):
device["roomHint"] = room
@ -681,6 +714,16 @@ class GoogleEntity:
return attrs
@callback
def notifications_serialize(self) -> dict[str, Any] | None:
"""Serialize the payload for notifications to be sent."""
notifications: dict[str, Any] = {}
for trt in self.traits():
deep_update(notifications, trt.query_notifications() or {})
return notifications or None
@callback
def reachable_device_serialize(self):
"""Serialize entity for a REACHABLE_DEVICE response."""

View File

@ -158,7 +158,7 @@ class GoogleConfig(AbstractConfig):
"""If an entity should have 2FA checked."""
return True
async def _async_request_sync_devices(self, agent_user_id: str):
async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus:
if CONF_SERVICE_ACCOUNT in self._config:
return await self.async_call_homegraph_api(
REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id}
@ -220,14 +220,18 @@ class GoogleConfig(AbstractConfig):
_LOGGER.error("Could not contact %s", url)
return HTTPStatus.INTERNAL_SERVER_ERROR
async def async_report_state(self, message, agent_user_id: str):
async def async_report_state(
self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None
) -> HTTPStatus:
"""Send a state report to Google."""
data = {
"requestId": uuid4().hex,
"agentUserId": agent_user_id,
"payload": message,
}
await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data)
if event_id is not None:
data["eventId"] = event_id
return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data)
class GoogleAssistantView(HomeAssistantView):

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections import deque
import logging
from typing import Any
from uuid import uuid4
from homeassistant.const import MATCH_ALL
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback
@ -30,7 +31,7 @@ _LOGGER = logging.getLogger(__name__)
@callback
def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig):
"""Enable state reporting."""
"""Enable state and notification reporting."""
checker = None
unsub_pending: CALLBACK_TYPE | None = None
pending: deque[dict[str, Any]] = deque([{}])
@ -79,6 +80,23 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
):
return
if (notifications := entity.notifications_serialize()) is not None:
event_id = uuid4().hex
payload = {
"devices": {"notifications": {entity.state.entity_id: notifications}}
}
_LOGGER.info(
"Sending event notification for entity %s",
entity.state.entity_id,
)
result = await google_config.async_sync_notification_all(event_id, payload)
if result != 200:
_LOGGER.error(
"Unable to send notification with result code: %s, check log for more"
" info",
result,
)
try:
entity_data = entity.query_serialize()
except SmartHomeError as err:

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
import logging
from typing import Any, TypeVar
@ -12,6 +13,7 @@ from homeassistant.components import (
camera,
climate,
cover,
event,
fan,
group,
humidifier,
@ -74,9 +76,10 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import DOMAIN as HA_DOMAIN
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant
from homeassistant.helpers.network import get_url
from homeassistant.util import color as color_util, dt as dt_util
from homeassistant.util.dt import utcnow
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@ -115,6 +118,7 @@ TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock"
TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed"
TRAIT_MODES = f"{PREFIX_TRAITS}Modes"
TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector"
TRAIT_OBJECTDETECTION = f"{PREFIX_TRAITS}ObjectDetection"
TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose"
TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume"
TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm"
@ -221,7 +225,7 @@ class _Trait(ABC):
def supported(domain, features, device_class, attributes):
"""Test if state is supported."""
def __init__(self, hass, state, config):
def __init__(self, hass: HomeAssistant, state, config) -> None:
"""Initialize a trait for a state."""
self.hass = hass
self.state = state
@ -231,10 +235,17 @@ class _Trait(ABC):
"""Return attributes for a sync request."""
raise NotImplementedError
def sync_options(self) -> dict[str, Any]:
"""Add options for the sync request."""
return {}
def query_attributes(self):
"""Return the attributes of this trait for this entity."""
raise NotImplementedError
def query_notifications(self) -> dict[str, Any] | None:
"""Return notifications payload."""
def can_execute(self, command, params):
"""Test if command can be executed."""
return command in self.commands
@ -335,6 +346,60 @@ class CameraStreamTrait(_Trait):
}
@register_trait
class ObjectDetection(_Trait):
"""Trait to object detection.
https://developers.google.com/actions/smarthome/traits/objectdetection
"""
name = TRAIT_OBJECTDETECTION
commands = []
@staticmethod
def supported(domain, features, device_class, _) -> bool:
"""Test if state is supported."""
return (
domain == event.DOMAIN and device_class == event.EventDeviceClass.DOORBELL
)
def sync_attributes(self):
"""Return ObjectDetection attributes for a sync request."""
return {}
def sync_options(self) -> dict[str, Any]:
"""Add options for the sync request."""
return {"notificationSupportedByAgent": True}
def query_attributes(self):
"""Return ObjectDetection query attributes."""
return {}
def query_notifications(self) -> dict[str, Any] | None:
"""Return notifications payload."""
if self.state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}:
return None
# Only notify if last event was less then 30 seconds ago
time_stamp = datetime.fromisoformat(self.state.state)
if (utcnow() - time_stamp) > timedelta(seconds=30):
return None
return {
"ObjectDetection": {
"objects": {
"unclassified": 1,
},
"priority": 0,
"detectionTimestamp": int(time_stamp.timestamp() * 1000),
},
}
async def execute(self, command, data, params, challenge):
"""Execute an ObjectDetection command."""
@register_trait
class OnOffTrait(_Trait):
"""Trait to offer basic on and off functionality.

View File

@ -87,6 +87,7 @@
'binary_sensor',
'climate',
'cover',
'event',
'fan',
'group',
'humidifier',

View File

@ -306,7 +306,7 @@ async def test_agent_user_id_connect() -> None:
@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}])
async def test_report_state_all(agents) -> None:
"""Test a disconnect message."""
"""Test sync of all states."""
config = MockConfig(agent_user_ids=agents)
data = {}
with patch.object(config, "async_report_state") as mock:
@ -314,6 +314,28 @@ async def test_report_state_all(agents) -> None:
assert sorted(mock.mock_calls) == sorted(call(data, agent) for agent in agents)
@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}])
async def test_sync_entities(agents) -> None:
"""Test sync of all entities."""
config = MockConfig(agent_user_ids=agents)
with patch.object(
config, "async_sync_entities", return_value=HTTPStatus.NO_CONTENT
) as mock:
await config.async_sync_entities_all()
assert sorted(mock.mock_calls) == sorted(call(agent) for agent in agents)
@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}])
async def test_sync_notifications(agents) -> None:
"""Test sync of notifications."""
config = MockConfig(agent_user_ids=agents)
with patch.object(
config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT
) as mock:
await config.async_sync_notification_all("1234", {})
assert not agents or bool(mock.mock_calls) and agents
@pytest.mark.parametrize(
("agents", "result"),
[({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)],

View File

@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
from unittest.mock import ANY, patch
from uuid import uuid4
import pytest
@ -195,6 +196,38 @@ async def test_report_state(
)
async def test_report_event(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_storage: dict[str, Any],
) -> None:
"""Test the report event function."""
agent_user_id = "user"
config = GoogleConfig(hass, DUMMY_CONFIG)
await config.async_initialize()
await config.async_connect_agent_user(agent_user_id)
message = {"devices": {}}
with patch.object(config, "async_call_homegraph_api"):
# Wait for google_assistant.helpers.async_initialize.sync_google to be called
await hass.async_block_till_done()
event_id = uuid4().hex
with patch.object(config, "async_call_homegraph_api") as mock_call:
# Wait for google_assistant.helpers.async_initialize.sync_google to be called
await config.async_report_state(message, agent_user_id, event_id=event_id)
mock_call.assert_called_once_with(
REPORT_STATE_BASE_URL,
{
"requestId": ANY,
"agentUserId": agent_user_id,
"payload": message,
"eventId": event_id,
},
)
async def test_google_config_local_fulfillment(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,

View File

@ -1,5 +1,7 @@
"""Test Google report state."""
from datetime import timedelta
from datetime import datetime, timedelta
from http import HTTPStatus
from time import mktime
from unittest.mock import AsyncMock, patch
import pytest
@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from . import BASIC_CONFIG
from . import BASIC_CONFIG, MockConfig
from tests.common import async_fire_time_changed
@ -21,6 +23,9 @@ async def test_report_state(
assert await async_setup_component(hass, "switch", {})
hass.states.async_set("light.ceiling", "off")
hass.states.async_set("switch.ac", "on")
hass.states.async_set(
"event.doorbell", "unknown", attributes={"device_class": "doorbell"}
)
with patch.object(
BASIC_CONFIG, "async_report_state_all", AsyncMock()
@ -37,6 +42,7 @@ async def test_report_state(
"states": {
"light.ceiling": {"on": False, "online": True},
"switch.ac": {"on": True, "online": True},
"event.doorbell": {"online": True},
}
}
}
@ -128,3 +134,145 @@ async def test_report_state(
await hass.async_block_till_done()
assert len(mock_report.mock_calls) == 0
@pytest.mark.freeze_time("2023-08-01 00:00:00")
async def test_report_notifications(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test report state works."""
config = MockConfig(agent_user_ids={"1"})
assert await async_setup_component(hass, "event", {})
hass.states.async_set(
"event.doorbell", "unknown", attributes={"device_class": "doorbell"}
)
with patch.object(
config, "async_report_state_all", AsyncMock()
) as mock_report, patch.object(report_state, "INITIAL_REPORT_DELAY", 0):
report_state.async_enable_report_state(hass, config)
async_fire_time_changed(
hass, datetime.fromisoformat("2023-08-01T00:01:00+00:00")
)
await hass.async_block_till_done()
# Test that enabling report state does a report on event entities
assert len(mock_report.mock_calls) == 1
assert mock_report.mock_calls[0][1][0] == {
"devices": {
"states": {
"event.doorbell": {"online": True},
},
}
}
with patch.object(
config, "async_report_state", return_value=HTTPStatus(200)
) as mock_report_state:
event_time = datetime.fromisoformat("2023-08-01T00:02:57+00:00")
epoc_event_time = int(mktime(event_time.timetuple()))
hass.states.async_set(
"event.doorbell",
"2023-08-01T00:02:57+00:00",
attributes={"device_class": "doorbell"},
)
async_fire_time_changed(
hass, datetime.fromisoformat("2023-08-01T00:03:00+00:00")
)
await hass.async_block_till_done()
assert len(mock_report_state.mock_calls) == 1
notifications_payload = mock_report_state.mock_calls[0][1][0]["devices"][
"notifications"
]["event.doorbell"]
assert notifications_payload == {
"ObjectDetection": {
"objects": {"unclassified": 1},
"priority": 0,
"detectionTimestamp": epoc_event_time * 1000,
}
}
assert "Sending event notification for entity event.doorbell" in caplog.text
assert "Unable to send notification with result code" not in caplog.text
hass.states.async_set(
"event.doorbell", "unknown", attributes={"device_class": "doorbell"}
)
async_fire_time_changed(
hass, datetime.fromisoformat("2023-08-01T01:01:00+00:00")
)
await hass.async_block_till_done()
# Test the notification request failed
caplog.clear()
with patch.object(
config, "async_report_state", return_value=HTTPStatus(500)
) as mock_report_state:
event_time = datetime.fromisoformat("2023-08-01T01:02:57+00:00")
epoc_event_time = int(mktime(event_time.timetuple()))
hass.states.async_set(
"event.doorbell",
"2023-08-01T01:02:57+00:00",
attributes={"device_class": "doorbell"},
)
async_fire_time_changed(
hass, datetime.fromisoformat("2023-08-01T01:03:00+00:00")
)
await hass.async_block_till_done()
assert len(mock_report_state.mock_calls) == 2
for call in mock_report_state.mock_calls:
if "notifications" in call[1][0]["devices"]:
notifications = call[1][0]["devices"]["notifications"]
elif "states" in call[1][0]["devices"]:
states = call[1][0]["devices"]["states"]
assert notifications["event.doorbell"] == {
"ObjectDetection": {
"objects": {"unclassified": 1},
"priority": 0,
"detectionTimestamp": epoc_event_time * 1000,
}
}
assert states["event.doorbell"] == {"online": True}
assert "Sending event notification for entity event.doorbell" in caplog.text
assert (
"Unable to send notification with result code: 500, check log for more info"
in caplog.text
)
# Test disconnecting agent user
caplog.clear()
with patch.object(
config, "async_report_state", return_value=HTTPStatus.NOT_FOUND
) as mock_report_state, patch.object(config, "async_disconnect_agent_user"):
event_time = datetime.fromisoformat("2023-08-01T01:03:57+00:00")
epoc_event_time = int(mktime(event_time.timetuple()))
hass.states.async_set(
"event.doorbell",
"2023-08-01T01:03:57+00:00",
attributes={"device_class": "doorbell"},
)
async_fire_time_changed(
hass, datetime.fromisoformat("2023-08-01T01:04:00+00:00")
)
await hass.async_block_till_done()
assert len(mock_report_state.mock_calls) == 2
for call in mock_report_state.mock_calls:
if "notifications" in call[1][0]["devices"]:
notifications = call[1][0]["devices"]["notifications"]
elif "states" in call[1][0]["devices"]:
states = call[1][0]["devices"]["states"]
assert notifications["event.doorbell"] == {
"ObjectDetection": {
"objects": {"unclassified": 1},
"priority": 0,
"detectionTimestamp": epoc_event_time * 1000,
}
}
assert states["event.doorbell"] == {"online": True}
assert "Sending event notification for entity event.doorbell" in caplog.text
assert (
"Unable to send notification with result code: 404, check log for more info"
in caplog.text
)

View File

@ -11,6 +11,7 @@ from homeassistant.components import (
camera,
climate,
cover,
event,
fan,
group,
humidifier,
@ -220,6 +221,42 @@ async def test_onoff_input_boolean(hass: HomeAssistant) -> None:
assert off_calls[0].data == {ATTR_ENTITY_ID: "input_boolean.bla"}
@pytest.mark.freeze_time("2023-08-01T00:02:57+00:00")
async def test_doorbell_event(hass: HomeAssistant) -> None:
"""Test doorbell event trait support for input_boolean domain."""
assert trait.ObjectDetection.supported(event.DOMAIN, 0, "doorbell", None)
state = State(
"event.bla",
"2023-08-01T00:02:57+00:00",
attributes={"device_class": "doorbell"},
)
trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG)
assert not trt_od.sync_attributes()
assert trt_od.sync_options() == {"notificationSupportedByAgent": True}
assert not trt_od.query_attributes()
time_stamp = datetime.fromisoformat(state.state)
assert trt_od.query_notifications() == {
"ObjectDetection": {
"objects": {
"unclassified": 1,
},
"priority": 0,
"detectionTimestamp": int(time_stamp.timestamp() * 1000),
}
}
# Test that stale notifications (older than 30 s) are dropped
state = State(
"event.bla",
"2023-08-01T00:02:22+00:00",
attributes={"device_class": "doorbell"},
)
trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG)
assert trt_od.query_notifications() is None
async def test_onoff_switch(hass: HomeAssistant) -> None:
"""Test OnOff trait support for switch domain."""
assert helpers.get_google_type(switch.DOMAIN, None) is not None