From b9b5fe6be875b2e9108031fd49b0ed443686e8b0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 23 Jun 2023 20:34:34 -0700 Subject: [PATCH] Add service response data for listing calendar events (#94759) * Add service response data for listing calendar events Add the capability of response data for for the entity component. * Rename input arguments and add service description * Improve list events to be more user friendly Allow the end date to be determined based on a relative time duration. Make the start time optional and set to "now". Add additional test coverage. Update demo calendar to actually perform date range checks. * Wrap docstrings properly. * Increase test coverage * Update to use new API calls * Readability improvements * Wrap docstrings * Require at least one of end or duration * Check for multiple entity matches earlier in the request * Update documentation strings --- homeassistant/components/calendar/__init__.py | 51 +++++++- homeassistant/components/calendar/const.py | 1 + .../components/calendar/services.yaml | 24 ++++ homeassistant/components/demo/calendar.py | 11 +- homeassistant/helpers/entity.py | 9 +- homeassistant/helpers/entity_component.py | 18 ++- homeassistant/helpers/service.py | 51 ++++++-- tests/components/calendar/test_init.py | 121 +++++++++++++++++- tests/helpers/test_entity_component.py | 76 ++++++++++- 9 files changed, 331 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 2cb807169ea..5d0d2526bf2 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -8,7 +8,7 @@ from http import HTTPStatus from itertools import groupby import logging import re -from typing import Any, cast, final +from typing import Any, Final, cast, final from aiohttp import web from dateutil.rrule import rrulestr @@ -19,7 +19,12 @@ from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPOR from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -32,10 +37,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType from .const import ( CONF_EVENT, EVENT_DESCRIPTION, + EVENT_DURATION, EVENT_END, EVENT_END_DATE, EVENT_END_DATETIME, @@ -250,6 +257,21 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SERVICE_LIST_EVENTS: Final = "list_events" +SERVICE_LIST_EVENTS_SCHEMA: Final = vol.All( + cv.has_at_least_one_key(EVENT_END_DATETIME, EVENT_DURATION), + cv.has_at_most_one_key(EVENT_END_DATETIME, EVENT_DURATION), + cv.make_entity_service_schema( + { + vol.Optional(EVENT_START_DATETIME): datetime.datetime, + vol.Optional(EVENT_END_DATETIME): datetime.datetime, + vol.Optional(EVENT_DURATION): vol.All( + cv.time_period, cv.positive_timedelta + ), + } + ), +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for calendars.""" @@ -274,7 +296,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_create_event, required_features=[CalendarEntityFeature.CREATE_EVENT], ) - + component.async_register_entity_service( + SERVICE_LIST_EVENTS, + SERVICE_LIST_EVENTS_SCHEMA, + async_list_events_service, + supports_response=SupportsResponse.ONLY, + ) await component.async_setup(config) return True @@ -743,3 +770,21 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: EVENT_END: end, } await entity.async_create_event(**params) + + +async def async_list_events_service( + calendar: CalendarEntity, service_call: ServiceCall +) -> ServiceResponse: + """List events on a calendar during a time drange.""" + start = service_call.data.get(EVENT_START_DATETIME, dt_util.now()) + if EVENT_DURATION in service_call.data: + end = start + service_call.data[EVENT_DURATION] + else: + end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events(calendar.hass, start, end) + events: list[JsonValueType] = [ + dataclasses.asdict(event) for event in calendar_event_list + ] + return { + "events": events, + } diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index 3fbab6742a9..2d4f0dfe0ba 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -40,3 +40,4 @@ EVENT_TIME_FIELDS = { EVENT_IN, } EVENT_TYPES = "event_types" +EVENT_DURATION = "duration" diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 5d1a3ccf0f4..af69882bba5 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -52,3 +52,27 @@ create_event: example: "Conference Room - F123, Bldg. 002" selector: text: +list_events: + name: List event + description: List events on a calendar within a time range. + target: + entity: + domain: calendar + fields: + start_date_time: + name: Start time + description: Return active events after this time (exclusive). When not set, defaults to now. + example: "2022-03-22 20:00:00" + selector: + datetime: + end_date_time: + name: End time + description: Return active events before this time (exclusive). Cannot be used with 'duration'. + example: "2022-03-22 22:00:00" + selector: + datetime: + duration: + name: Duration + description: Return active events from start_date_time until the specified duration. + selector: + duration: diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index ae546361d8f..73b45a55640 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -27,10 +27,10 @@ def setup_platform( def calendar_data_future() -> CalendarEvent: """Representation of a Demo Calendar for a future event.""" - one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) + half_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) return CalendarEvent( - start=one_hour_from_now, - end=one_hour_from_now + datetime.timedelta(minutes=60), + start=half_hour_from_now, + end=half_hour_from_now + datetime.timedelta(minutes=60), summary="Future Event", description="Future Description", location="Future Location", @@ -67,4 +67,9 @@ class DemoCalendar(CalendarEntity): end_date: datetime.datetime, ) -> list[CalendarEvent]: """Return calendar events within a datetime range.""" + assert start_date < end_date + if self._event.start_datetime_local >= end_date: + return [] + if self._event.end_datetime_local < start_date: + return [] return [self._event] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 97b3485c893..673d2c0b4d5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -12,7 +12,7 @@ import logging import math import sys from timeit import default_timer as timer -from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, final +from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, final import voluptuous as vol @@ -49,6 +49,9 @@ from .typing import UNDEFINED, StateType, UndefinedType if TYPE_CHECKING: from .entity_platform import EntityPlatform + +_T = TypeVar("_T") + _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" @@ -1130,13 +1133,13 @@ class Entity(ABC): """Return the representation.""" return f"" - async def async_request_call(self, coro: Coroutine[Any, Any, Any]) -> None: + async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: """Process request batched.""" if self.parallel_updates: await self.parallel_updates.acquire() try: - await coro + return await coro finally: if self.parallel_updates: self.parallel_updates.release() diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2e8e23fcee9..af1b87ec0fa 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -19,7 +19,14 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + Event, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform @@ -217,18 +224,21 @@ class EntityComponent(Generic[_EntityT]): schema: dict[str | vol.Marker, Any] | vol.Schema, func: str | Callable[..., Any], required_features: list[int] | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register an entity service.""" if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> None: + async def handle_service(call: ServiceCall) -> ServiceResponse: """Handle the service.""" - await service.entity_service_call( + return await service.entity_service_call( self.hass, self._platforms.values(), func, call, required_features ) - self.hass.services.async_register(self.domain, name, handle_service, schema) + self.hass.services.async_register( + self.domain, name, handle_service, schema, supports_response + ) async def async_setup_platform( self, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a1ecdc75c71..3eacc8d6629 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial, wraps @@ -26,7 +26,13 @@ from homeassistant.const import ( ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + callback, +) from homeassistant.exceptions import ( HomeAssistantError, TemplateError, @@ -672,10 +678,10 @@ def async_set_service_schema( async def entity_service_call( # noqa: C901 hass: HomeAssistant, platforms: Iterable[EntityPlatform], - func: str | Callable[..., Any], + func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], call: ServiceCall, required_features: Iterable[int] | None = None, -) -> None: +) -> ServiceResponse | None: """Handle an entity service call. Calls all platforms simultaneously. @@ -791,7 +797,16 @@ async def entity_service_call( # noqa: C901 entities.append(entity) if not entities: - return + if call.return_response: + raise HomeAssistantError( + "Service call requested response data but did not match any entities" + ) + return None + + if call.return_response and len(entities) != 1: + raise HomeAssistantError( + "Service call requested response data but matched more than one entity" + ) done, pending = await asyncio.wait( [ @@ -804,8 +819,10 @@ async def entity_service_call( # noqa: C901 ] ) assert not pending - for future in done: - future.result() # pop exception if have + + response_data: ServiceResponse | None + for task in done: + response_data = task.result() # pop exception if have tasks: list[asyncio.Task[None]] = [] @@ -824,28 +841,32 @@ async def entity_service_call( # noqa: C901 for future in done: future.result() # pop exception if have + return response_data if call.return_response else None + async def _handle_entity_call( hass: HomeAssistant, entity: Entity, - func: str | Callable[..., Any], + func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], data: dict | ServiceCall, context: Context, -) -> None: +) -> ServiceResponse: """Handle calling service method.""" entity.async_set_context(context) + task: asyncio.Future[ServiceResponse] | None if isinstance(func, str): - result = hass.async_run_job( + task = hass.async_run_job( partial(getattr(entity, func), **data) # type: ignore[arg-type] ) else: - result = hass.async_run_job(func, entity, data) + task = hass.async_run_job(func, entity, data) # Guard because callback functions do not return a task when passed to # async_run_job. - if result is not None: - await result + result: ServiceResponse | None = None + if task is not None: + result = await task if asyncio.iscoroutine(result): _LOGGER.error( @@ -856,7 +877,9 @@ async def _handle_entity_call( func, entity.entity_id, ) - await result + result = await result + + return result @bind_hass diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index d58932ce898..97292221819 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -10,7 +10,7 @@ import pytest import voluptuous as vol from homeassistant.bootstrap import async_setup_component -from homeassistant.components.calendar import DOMAIN +from homeassistant.components.calendar import DOMAIN, SERVICE_LIST_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util @@ -384,3 +384,122 @@ async def test_create_event_service_invalid_params( target={"entity_id": "calendar.calendar_1"}, blocking=True, ) + + +async def test_list_events_service(hass: HomeAssistant) -> None: + """Test listing events from the service call using exlplicit start and end time.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + start = dt_util.now() + end = start + timedelta(days=1) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + "start_date_time": start, + "end_date_time": end, + }, + blocking=True, + return_response=True, + ) + assert response + assert "events" in response + events = response["events"] + assert len(events) == 1 + assert events[0]["summary"] == "Future Event" + + +@pytest.mark.parametrize( + ("entity", "duration", "expected_events"), + [ + # Calendar 1 has an hour long event starting in 30 minutes. No events in the + # next 15 minutes, but it shows up an hour from now. + ("calendar.calendar_1", "00:15:00", []), + ("calendar.calendar_1", "01:00:00", ["Future Event"]), + # Calendar 2 has a active event right now + ("calendar.calendar_2", "00:15:00", ["Current Event"]), + ], +) +async def test_list_events_service_duration( + hass: HomeAssistant, + entity: str, + duration: str, + expected_events: list[str], +) -> None: + """Test listing events using a time duration.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": entity, + "duration": duration, + }, + blocking=True, + return_response=True, + ) + assert response + assert "events" in response + events = response["events"] + assert [event["summary"] for event in events] == expected_events + + +async def test_list_events_positive_duration(hass: HomeAssistant) -> None: + """Test listing events requires a positive duration.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + with pytest.raises(vol.Invalid, match="should be positive"): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + "duration": "-01:00:00", + }, + blocking=True, + return_response=True, + ) + + +async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: + """Test listing events specifying fields that are exclusive.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + end = dt_util.now() + timedelta(days=1) + + with pytest.raises(vol.Invalid, match="at most one of"): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + "end_date_time": end, + "duration": "01:00:00", + }, + blocking=True, + return_response=True, + ) + + +async def test_list_events_missing_fields(hass: HomeAssistant) -> None: + """Test listing events missing some required fields.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + with pytest.raises(vol.Invalid, match="at least one of"): + await hass.services.async_call( + DOMAIN, + SERVICE_LIST_EVENTS, + { + "entity_id": "calendar.calendar_1", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 4c9847bb3d2..4119ccc6e85 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -14,8 +14,14 @@ from homeassistant.const import ( ENTITY_MATCH_NONE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent, async_update_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -474,7 +480,7 @@ async def test_extract_all_use_match_all( async def test_register_entity_service(hass: HomeAssistant) -> None: - """Test not expanding a group.""" + """Test registering an enttiy service and calling it.""" entity = MockEntity(entity_id=f"{DOMAIN}.entity") calls = [] @@ -524,6 +530,70 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: assert len(calls) == 2 +async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: + """Test an enttiy service that does not support response data.""" + entity = MockEntity(entity_id=f"{DOMAIN}.entity") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": "response-value"} + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity]) + + component.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + DOMAIN, + "hello", + service_data={"entity_id": entity.entity_id, "some": "data"}, + blocking=True, + return_response=True, + ) + assert response_data == {"response-key": "response-value"} + + +async def test_register_entity_service_response_data_multiple_matches( + hass: HomeAssistant, +) -> None: + """Test asking for service response data but matching many entities.""" + entity1 = MockEntity(entity_id=f"{DOMAIN}.entity1") + entity2 = MockEntity(entity_id=f"{DOMAIN}.entity2") + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + raise ValueError("Should not be invoked") + + component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) + await component.async_add_entities([entity1, entity2]) + + component.async_register_entity_service( + "hello", + {}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + with pytest.raises(HomeAssistantError, match="matched more than one entity"): + await hass.services.async_call( + DOMAIN, + "hello", + target={"entity_id": [entity1.entity_id, entity2.entity_id]}, + blocking=True, + return_response=True, + ) + + async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: """Test that we shutdown platforms on stop.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None])