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, {}) assistant_options = settings.get(CLOUD_GOOGLE, {})
return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) 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.""" """Send a state report to Google."""
try: try:
await self._cloud.google_report_state.async_send_message(message) await self._cloud.google_report_state.async_send_message(message)
except ErrorResponse as err: except ErrorResponse as err:
_LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) _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.""" """Trigger a sync with Google."""
if self._sync_entities_lock.locked(): if self._sync_entities_lock.locked():
return HTTPStatus.OK return HTTPStatus.OK

View File

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

View File

@ -9,6 +9,7 @@ from functools import lru_cache
from http import HTTPStatus from http import HTTPStatus
import logging import logging
import pprint import pprint
from typing import Any
from aiohttp.web import json_response from aiohttp.web import json_response
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
@ -183,7 +184,9 @@ class AbstractConfig(ABC):
"""If an entity should have 2FA checked.""" """If an entity should have 2FA checked."""
return True 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.""" """Send a state report to Google."""
raise NotImplementedError raise NotImplementedError
@ -234,6 +237,33 @@ class AbstractConfig(ABC):
) )
return max(res, default=204) 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 @callback
def async_schedule_google_sync(self, agent_user_id: str): def async_schedule_google_sync(self, agent_user_id: str):
"""Schedule a sync.""" """Schedule a sync."""
@ -617,7 +647,6 @@ class GoogleEntity:
state.domain, state.attributes.get(ATTR_DEVICE_CLASS) state.domain, state.attributes.get(ATTR_DEVICE_CLASS)
), ),
} }
# Add aliases # Add aliases
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or ( if (config_aliases := entity_config.get(CONF_ALIASES, [])) or (
entity_entry and entity_entry.aliases entity_entry and entity_entry.aliases
@ -639,6 +668,10 @@ class GoogleEntity:
for trt in traits: for trt in traits:
device["attributes"].update(trt.sync_attributes()) device["attributes"].update(trt.sync_attributes())
# Add trait options
for trt in traits:
device.update(trt.sync_options())
# Add roomhint # Add roomhint
if room := entity_config.get(CONF_ROOM_HINT): if room := entity_config.get(CONF_ROOM_HINT):
device["roomHint"] = room device["roomHint"] = room
@ -681,6 +714,16 @@ class GoogleEntity:
return attrs 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 @callback
def reachable_device_serialize(self): def reachable_device_serialize(self):
"""Serialize entity for a REACHABLE_DEVICE response.""" """Serialize entity for a REACHABLE_DEVICE response."""

View File

@ -158,7 +158,7 @@ class GoogleConfig(AbstractConfig):
"""If an entity should have 2FA checked.""" """If an entity should have 2FA checked."""
return True 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: if CONF_SERVICE_ACCOUNT in self._config:
return await self.async_call_homegraph_api( return await self.async_call_homegraph_api(
REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id}
@ -220,14 +220,18 @@ class GoogleConfig(AbstractConfig):
_LOGGER.error("Could not contact %s", url) _LOGGER.error("Could not contact %s", url)
return HTTPStatus.INTERNAL_SERVER_ERROR 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.""" """Send a state report to Google."""
data = { data = {
"requestId": uuid4().hex, "requestId": uuid4().hex,
"agentUserId": agent_user_id, "agentUserId": agent_user_id,
"payload": message, "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): class GoogleAssistantView(HomeAssistantView):

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections import deque from collections import deque
import logging import logging
from typing import Any from typing import Any
from uuid import uuid4
from homeassistant.const import MATCH_ALL from homeassistant.const import MATCH_ALL
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback
@ -30,7 +31,7 @@ _LOGGER = logging.getLogger(__name__)
@callback @callback
def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig):
"""Enable state reporting.""" """Enable state and notification reporting."""
checker = None checker = None
unsub_pending: CALLBACK_TYPE | None = None unsub_pending: CALLBACK_TYPE | None = None
pending: deque[dict[str, Any]] = deque([{}]) pending: deque[dict[str, Any]] = deque([{}])
@ -79,6 +80,23 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
): ):
return 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: try:
entity_data = entity.query_serialize() entity_data = entity.query_serialize()
except SmartHomeError as err: except SmartHomeError as err:

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime, timedelta
import logging import logging
from typing import Any, TypeVar from typing import Any, TypeVar
@ -12,6 +13,7 @@ from homeassistant.components import (
camera, camera,
climate, climate,
cover, cover,
event,
fan, fan,
group, group,
humidifier, humidifier,
@ -74,9 +76,10 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
UnitOfTemperature, 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.helpers.network import get_url
from homeassistant.util import color as color_util, dt as dt_util from homeassistant.util import color as color_util, dt as dt_util
from homeassistant.util.dt import utcnow
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
ordered_list_item_to_percentage, ordered_list_item_to_percentage,
percentage_to_ordered_list_item, percentage_to_ordered_list_item,
@ -115,6 +118,7 @@ TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock"
TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed"
TRAIT_MODES = f"{PREFIX_TRAITS}Modes" TRAIT_MODES = f"{PREFIX_TRAITS}Modes"
TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector" TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector"
TRAIT_OBJECTDETECTION = f"{PREFIX_TRAITS}ObjectDetection"
TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose"
TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume"
TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm"
@ -221,7 +225,7 @@ class _Trait(ABC):
def supported(domain, features, device_class, attributes): def supported(domain, features, device_class, attributes):
"""Test if state is supported.""" """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.""" """Initialize a trait for a state."""
self.hass = hass self.hass = hass
self.state = state self.state = state
@ -231,10 +235,17 @@ class _Trait(ABC):
"""Return attributes for a sync request.""" """Return attributes for a sync request."""
raise NotImplementedError raise NotImplementedError
def sync_options(self) -> dict[str, Any]:
"""Add options for the sync request."""
return {}
def query_attributes(self): def query_attributes(self):
"""Return the attributes of this trait for this entity.""" """Return the attributes of this trait for this entity."""
raise NotImplementedError raise NotImplementedError
def query_notifications(self) -> dict[str, Any] | None:
"""Return notifications payload."""
def can_execute(self, command, params): def can_execute(self, command, params):
"""Test if command can be executed.""" """Test if command can be executed."""
return command in self.commands 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 @register_trait
class OnOffTrait(_Trait): class OnOffTrait(_Trait):
"""Trait to offer basic on and off functionality. """Trait to offer basic on and off functionality.

View File

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

View File

@ -306,7 +306,7 @@ async def test_agent_user_id_connect() -> None:
@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) @pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}])
async def test_report_state_all(agents) -> None: async def test_report_state_all(agents) -> None:
"""Test a disconnect message.""" """Test sync of all states."""
config = MockConfig(agent_user_ids=agents) config = MockConfig(agent_user_ids=agents)
data = {} data = {}
with patch.object(config, "async_report_state") as mock: 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) 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( @pytest.mark.parametrize(
("agents", "result"), ("agents", "result"),
[({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)], [({}, 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 http import HTTPStatus
from typing import Any from typing import Any
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
from uuid import uuid4
import pytest 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( async def test_google_config_local_fulfillment(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,

View File

@ -1,5 +1,7 @@
"""Test Google report state.""" """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 from unittest.mock import AsyncMock, patch
import pytest import pytest
@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from . import BASIC_CONFIG from . import BASIC_CONFIG, MockConfig
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
@ -21,6 +23,9 @@ async def test_report_state(
assert await async_setup_component(hass, "switch", {}) assert await async_setup_component(hass, "switch", {})
hass.states.async_set("light.ceiling", "off") hass.states.async_set("light.ceiling", "off")
hass.states.async_set("switch.ac", "on") hass.states.async_set("switch.ac", "on")
hass.states.async_set(
"event.doorbell", "unknown", attributes={"device_class": "doorbell"}
)
with patch.object( with patch.object(
BASIC_CONFIG, "async_report_state_all", AsyncMock() BASIC_CONFIG, "async_report_state_all", AsyncMock()
@ -37,6 +42,7 @@ async def test_report_state(
"states": { "states": {
"light.ceiling": {"on": False, "online": True}, "light.ceiling": {"on": False, "online": True},
"switch.ac": {"on": True, "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() await hass.async_block_till_done()
assert len(mock_report.mock_calls) == 0 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, camera,
climate, climate,
cover, cover,
event,
fan, fan,
group, group,
humidifier, 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"} 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: async def test_onoff_switch(hass: HomeAssistant) -> None:
"""Test OnOff trait support for switch domain.""" """Test OnOff trait support for switch domain."""
assert helpers.get_google_type(switch.DOMAIN, None) is not None assert helpers.get_google_type(switch.DOMAIN, None) is not None