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
This commit is contained in:
Allen Porter 2023-06-23 20:34:34 -07:00 committed by GitHub
parent 65454c945d
commit b9b5fe6be8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 331 additions and 31 deletions

View File

@ -8,7 +8,7 @@ from http import HTTPStatus
from itertools import groupby from itertools import groupby
import logging import logging
import re import re
from typing import Any, cast, final from typing import Any, Final, cast, final
from aiohttp import web from aiohttp import web
from dateutil.rrule import rrulestr 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.components.websocket_api.connection import ActiveConnection
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON 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 from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401 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.template import DATE_STR_FORMAT
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import ( from .const import (
CONF_EVENT, CONF_EVENT,
EVENT_DESCRIPTION, EVENT_DESCRIPTION,
EVENT_DURATION,
EVENT_END, EVENT_END,
EVENT_END_DATE, EVENT_END_DATE,
EVENT_END_DATETIME, EVENT_END_DATETIME,
@ -250,6 +257,21 @@ CALENDAR_EVENT_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Track states and offer events for calendars.""" """Track states and offer events for calendars."""
@ -274,7 +296,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_create_event, async_create_event,
required_features=[CalendarEntityFeature.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) await component.async_setup(config)
return True return True
@ -743,3 +770,21 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None:
EVENT_END: end, EVENT_END: end,
} }
await entity.async_create_event(**params) 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,
}

View File

@ -40,3 +40,4 @@ EVENT_TIME_FIELDS = {
EVENT_IN, EVENT_IN,
} }
EVENT_TYPES = "event_types" EVENT_TYPES = "event_types"
EVENT_DURATION = "duration"

View File

@ -52,3 +52,27 @@ create_event:
example: "Conference Room - F123, Bldg. 002" example: "Conference Room - F123, Bldg. 002"
selector: selector:
text: 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:

View File

@ -27,10 +27,10 @@ def setup_platform(
def calendar_data_future() -> CalendarEvent: def calendar_data_future() -> CalendarEvent:
"""Representation of a Demo Calendar for a future event.""" """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( return CalendarEvent(
start=one_hour_from_now, start=half_hour_from_now,
end=one_hour_from_now + datetime.timedelta(minutes=60), end=half_hour_from_now + datetime.timedelta(minutes=60),
summary="Future Event", summary="Future Event",
description="Future Description", description="Future Description",
location="Future Location", location="Future Location",
@ -67,4 +67,9 @@ class DemoCalendar(CalendarEntity):
end_date: datetime.datetime, end_date: datetime.datetime,
) -> list[CalendarEvent]: ) -> list[CalendarEvent]:
"""Return calendar events within a datetime range.""" """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] return [self._event]

View File

@ -12,7 +12,7 @@ import logging
import math import math
import sys import sys
from timeit import default_timer as timer 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 import voluptuous as vol
@ -49,6 +49,9 @@ from .typing import UNDEFINED, StateType, UndefinedType
if TYPE_CHECKING: if TYPE_CHECKING:
from .entity_platform import EntityPlatform from .entity_platform import EntityPlatform
_T = TypeVar("_T")
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SLOW_UPDATE_WARNING = 10 SLOW_UPDATE_WARNING = 10
DATA_ENTITY_SOURCE = "entity_info" DATA_ENTITY_SOURCE = "entity_info"
@ -1130,13 +1133,13 @@ class Entity(ABC):
"""Return the representation.""" """Return the representation."""
return f"<entity {self.entity_id}={self._stringify_state(self.available)}>" return f"<entity {self.entity_id}={self._stringify_state(self.available)}>"
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.""" """Process request batched."""
if self.parallel_updates: if self.parallel_updates:
await self.parallel_updates.acquire() await self.parallel_updates.acquire()
try: try:
await coro return await coro
finally: finally:
if self.parallel_updates: if self.parallel_updates:
self.parallel_updates.release() self.parallel_updates.release()

View File

@ -19,7 +19,14 @@ from homeassistant.const import (
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
EVENT_HOMEASSISTANT_STOP, 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.exceptions import HomeAssistantError
from homeassistant.loader import async_get_integration, bind_hass from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.setup import async_prepare_setup_platform from homeassistant.setup import async_prepare_setup_platform
@ -217,18 +224,21 @@ class EntityComponent(Generic[_EntityT]):
schema: dict[str | vol.Marker, Any] | vol.Schema, schema: dict[str | vol.Marker, Any] | vol.Schema,
func: str | Callable[..., Any], func: str | Callable[..., Any],
required_features: list[int] | None = None, required_features: list[int] | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
) -> None: ) -> None:
"""Register an entity service.""" """Register an entity service."""
if isinstance(schema, dict): if isinstance(schema, dict):
schema = cv.make_entity_service_schema(schema) schema = cv.make_entity_service_schema(schema)
async def handle_service(call: ServiceCall) -> None: async def handle_service(call: ServiceCall) -> ServiceResponse:
"""Handle the service.""" """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, 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( async def async_setup_platform(
self, self,

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Coroutine, Iterable
import dataclasses import dataclasses
from enum import Enum from enum import Enum
from functools import cache, partial, wraps from functools import cache, partial, wraps
@ -26,7 +26,13 @@ from homeassistant.const import (
ENTITY_MATCH_ALL, ENTITY_MATCH_ALL,
ENTITY_MATCH_NONE, ENTITY_MATCH_NONE,
) )
from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.core import (
Context,
HomeAssistant,
ServiceCall,
ServiceResponse,
callback,
)
from homeassistant.exceptions import ( from homeassistant.exceptions import (
HomeAssistantError, HomeAssistantError,
TemplateError, TemplateError,
@ -672,10 +678,10 @@ def async_set_service_schema(
async def entity_service_call( # noqa: C901 async def entity_service_call( # noqa: C901
hass: HomeAssistant, hass: HomeAssistant,
platforms: Iterable[EntityPlatform], platforms: Iterable[EntityPlatform],
func: str | Callable[..., Any], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]],
call: ServiceCall, call: ServiceCall,
required_features: Iterable[int] | None = None, required_features: Iterable[int] | None = None,
) -> None: ) -> ServiceResponse | None:
"""Handle an entity service call. """Handle an entity service call.
Calls all platforms simultaneously. Calls all platforms simultaneously.
@ -791,7 +797,16 @@ async def entity_service_call( # noqa: C901
entities.append(entity) entities.append(entity)
if not entities: 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( done, pending = await asyncio.wait(
[ [
@ -804,8 +819,10 @@ async def entity_service_call( # noqa: C901
] ]
) )
assert not pending 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]] = [] tasks: list[asyncio.Task[None]] = []
@ -824,28 +841,32 @@ async def entity_service_call( # noqa: C901
for future in done: for future in done:
future.result() # pop exception if have future.result() # pop exception if have
return response_data if call.return_response else None
async def _handle_entity_call( async def _handle_entity_call(
hass: HomeAssistant, hass: HomeAssistant,
entity: Entity, entity: Entity,
func: str | Callable[..., Any], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]],
data: dict | ServiceCall, data: dict | ServiceCall,
context: Context, context: Context,
) -> None: ) -> ServiceResponse:
"""Handle calling service method.""" """Handle calling service method."""
entity.async_set_context(context) entity.async_set_context(context)
task: asyncio.Future[ServiceResponse] | None
if isinstance(func, str): if isinstance(func, str):
result = hass.async_run_job( task = hass.async_run_job(
partial(getattr(entity, func), **data) # type: ignore[arg-type] partial(getattr(entity, func), **data) # type: ignore[arg-type]
) )
else: 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 # Guard because callback functions do not return a task when passed to
# async_run_job. # async_run_job.
if result is not None: result: ServiceResponse | None = None
await result if task is not None:
result = await task
if asyncio.iscoroutine(result): if asyncio.iscoroutine(result):
_LOGGER.error( _LOGGER.error(
@ -856,7 +877,9 @@ async def _handle_entity_call(
func, func,
entity.entity_id, entity.entity_id,
) )
await result result = await result
return result
@bind_hass @bind_hass

View File

@ -10,7 +10,7 @@ import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.bootstrap import async_setup_component 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.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.util.dt as dt_util 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"}, target={"entity_id": "calendar.calendar_1"},
blocking=True, 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,
)

View File

@ -14,8 +14,14 @@ from homeassistant.const import (
ENTITY_MATCH_NONE, ENTITY_MATCH_NONE,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import (
from homeassistant.exceptions import PlatformNotReady HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import discovery from homeassistant.helpers import discovery
from homeassistant.helpers.entity_component import EntityComponent, async_update_entity from homeassistant.helpers.entity_component import EntityComponent, async_update_entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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: 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") entity = MockEntity(entity_id=f"{DOMAIN}.entity")
calls = [] calls = []
@ -524,6 +530,70 @@ async def test_register_entity_service(hass: HomeAssistant) -> None:
assert len(calls) == 2 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: async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None:
"""Test that we shutdown platforms on stop.""" """Test that we shutdown platforms on stop."""
platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None])