From c5b32d63071452d3cfb03ff308abcca9b9f40c94 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 23:20:02 +0200 Subject: [PATCH] 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 --- .../components/cloud/google_config.py | 6 +- .../components/google_assistant/const.py | 4 + .../components/google_assistant/helpers.py | 47 +++++- .../components/google_assistant/http.py | 10 +- .../google_assistant/report_state.py | 20 ++- .../components/google_assistant/trait.py | 69 +++++++- .../snapshots/test_diagnostics.ambr | 1 + .../google_assistant/test_helpers.py | 24 ++- .../components/google_assistant/test_http.py | 33 ++++ .../google_assistant/test_report_state.py | 152 +++++++++++++++++- .../components/google_assistant/test_trait.py | 37 +++++ 11 files changed, 390 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 0a49c0b6ed6..c11ec47b2e5 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -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 diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 6ec8ca5d747..060f7ce50e5 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -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, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index c1b505b2bd4..ee8e5872348 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -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.""" diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 84d5e4a3364..c0e4f715c16 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -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): diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 52228bb8715..87af12ad0fc 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -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: diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 425a394b522..a39dfd3f3dc 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -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. diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 8d425ae0648..dffcddf5de5 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -87,6 +87,7 @@ 'binary_sensor', 'climate', 'cover', + 'event', 'fan', 'group', 'humidifier', diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 001e8ff0d07..57915968933 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -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)], diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 44dc40f5a47..62d2722c445 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -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, diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index d6f4043d2f7..4ec61b75171 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -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 + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index fcbf16c21c7..db4257bb621 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -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