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
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,
}

View File

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

View File

@ -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:

View File

@ -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]

View File

@ -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"<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."""
if self.parallel_updates:
await self.parallel_updates.acquire()
try:
await coro
return await coro
finally:
if self.parallel_updates:
self.parallel_updates.release()

View File

@ -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,

View File

@ -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

View File

@ -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,
)

View File

@ -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])