mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00: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:
parent
6387263007
commit
c5b32d6307
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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."""
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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.
|
||||||
|
@ -87,6 +87,7 @@
|
|||||||
'binary_sensor',
|
'binary_sensor',
|
||||||
'climate',
|
'climate',
|
||||||
'cover',
|
'cover',
|
||||||
|
'event',
|
||||||
'fan',
|
'fan',
|
||||||
'group',
|
'group',
|
||||||
'humidifier',
|
'humidifier',
|
||||||
|
@ -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)],
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user