mirror of
https://github.com/home-assistant/core.git
synced 2026-10-07 14:58:11 -04:00
Move calendar service registration to services module (#183589)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import datetime
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Final, cast, final, override
|
||||
from typing import Any, cast, final, override
|
||||
|
||||
from aiohttp import web
|
||||
import probatio
|
||||
@@ -23,14 +23,7 @@ from homeassistant.components.websocket_api import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
@@ -42,7 +35,8 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from .const import (
|
||||
from .const import ( # noqa: F401
|
||||
CREATE_EVENT_SERVICE,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
EVENT_DESCRIPTION,
|
||||
@@ -64,6 +58,8 @@ from .const import (
|
||||
EVENT_TIME_FIELDS,
|
||||
EVENT_TYPES,
|
||||
EVENT_UID,
|
||||
LIST_EVENT_FIELDS,
|
||||
SERVICE_GET_EVENTS,
|
||||
CalendarEntityFeature,
|
||||
CalendarEntityStateAttribute,
|
||||
CalendarEventStatus,
|
||||
@@ -78,12 +74,15 @@ from .helper import (
|
||||
get_datetime_local,
|
||||
has_consistent_timezone,
|
||||
has_min_duration,
|
||||
has_positive_interval,
|
||||
has_same_type,
|
||||
has_timezone,
|
||||
list_events_dict_factory,
|
||||
validate_rrule,
|
||||
)
|
||||
from .services import ( # noqa: F401
|
||||
CREATE_EVENT_SCHEMA,
|
||||
async_create_event,
|
||||
async_setup_services,
|
||||
)
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
@@ -96,45 +95,6 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60)
|
||||
EVENT_LISTENER_DEBOUNCE_COOLDOWN = 1.0 # seconds
|
||||
|
||||
|
||||
CREATE_EVENT_SERVICE = "create_event"
|
||||
CREATE_EVENT_SCHEMA = probatio.All(
|
||||
cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
|
||||
cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
probatio.Required(EVENT_SUMMARY): cv.string,
|
||||
probatio.Optional(EVENT_DESCRIPTION, default=""): cv.string,
|
||||
probatio.Optional(EVENT_LOCATION): cv.string,
|
||||
probatio.Inclusive(
|
||||
EVENT_START_DATE, "dates", "Start and end dates must both be specified"
|
||||
): cv.date,
|
||||
probatio.Inclusive(
|
||||
EVENT_END_DATE, "dates", "Start and end dates must both be specified"
|
||||
): cv.date,
|
||||
probatio.Inclusive(
|
||||
EVENT_START_DATETIME,
|
||||
"datetimes",
|
||||
"Start and end datetimes must both be specified",
|
||||
): cv.datetime,
|
||||
probatio.Inclusive(
|
||||
EVENT_END_DATETIME,
|
||||
"datetimes",
|
||||
"Start and end datetimes must both be specified",
|
||||
): cv.datetime,
|
||||
probatio.Optional(EVENT_IN): probatio.Schema(
|
||||
{
|
||||
probatio.Exclusive(EVENT_IN_DAYS, EVENT_TYPES): cv.positive_int,
|
||||
probatio.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
||||
as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
||||
has_min_duration(EVENT_START_DATE, EVENT_END_DATE, MIN_NEW_EVENT_DURATION),
|
||||
has_min_duration(EVENT_START_DATETIME, EVENT_END_DATETIME, MIN_NEW_EVENT_DURATION),
|
||||
)
|
||||
|
||||
WEBSOCKET_EVENT_SCHEMA = probatio.Schema(
|
||||
probatio.All(
|
||||
{
|
||||
@@ -169,22 +129,6 @@ CALENDAR_EVENT_SCHEMA = probatio.Schema(
|
||||
extra=probatio.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_GET_EVENTS: Final = "get_events"
|
||||
SERVICE_GET_EVENTS_SCHEMA: Final = probatio.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(
|
||||
{
|
||||
probatio.Optional(EVENT_START_DATETIME): cv.datetime,
|
||||
probatio.Optional(EVENT_END_DATETIME): cv.datetime,
|
||||
probatio.Optional(EVENT_DURATION): probatio.All(
|
||||
cv.time_period, cv.positive_timedelta
|
||||
),
|
||||
}
|
||||
),
|
||||
has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Track states and offer events for calendars."""
|
||||
@@ -202,18 +146,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_update)
|
||||
websocket_api.async_register_command(hass, handle_calendar_event_subscribe)
|
||||
|
||||
component.async_register_entity_service(
|
||||
CREATE_EVENT_SERVICE,
|
||||
CREATE_EVENT_SCHEMA,
|
||||
async_create_event,
|
||||
required_features=[CalendarEntityFeature.CREATE_EVENT],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_GET_EVENTS,
|
||||
SERVICE_GET_EVENTS_SCHEMA,
|
||||
async_get_events_service,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
async_setup_services(hass)
|
||||
await component.async_setup(config)
|
||||
return True
|
||||
|
||||
@@ -888,67 +821,3 @@ async def handle_calendar_event_subscribe(
|
||||
|
||||
# Push initial events only to the new subscriber
|
||||
entity.async_update_single_event_listener(start_date, end_date, event_listener)
|
||||
|
||||
|
||||
def _validate_timespan(
|
||||
values: dict[str, Any],
|
||||
) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]:
|
||||
"""Parse a create event service call.
|
||||
|
||||
Convert the args for a create event entity call.
|
||||
This converts the input service arguments into a
|
||||
`start` and `end` date or date time. This exists because
|
||||
service calls use `start_date` and `start_date_time`
|
||||
whereas the normal entity methods can take either a
|
||||
`datetime` or `date` as a single `start` argument.
|
||||
It also handles the other service call variations like "in days" as well.
|
||||
"""
|
||||
|
||||
if event_in := values.get(EVENT_IN):
|
||||
days = event_in.get(EVENT_IN_DAYS, 7 * event_in.get(EVENT_IN_WEEKS, 0))
|
||||
today = dt_util.now().date()
|
||||
return (
|
||||
today + datetime.timedelta(days=days),
|
||||
today + datetime.timedelta(days=days + 1),
|
||||
)
|
||||
|
||||
if EVENT_START_DATE in values and EVENT_END_DATE in values:
|
||||
return (values[EVENT_START_DATE], values[EVENT_END_DATE])
|
||||
|
||||
if EVENT_START_DATETIME in values and EVENT_END_DATETIME in values:
|
||||
return (values[EVENT_START_DATETIME], values[EVENT_END_DATETIME])
|
||||
|
||||
raise ValueError("Missing required fields to set start or end date/datetime")
|
||||
|
||||
|
||||
async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None:
|
||||
"""Add a new event to calendar."""
|
||||
# Convert parameters to format used by async_create_event
|
||||
(start, end) = _validate_timespan(call.data)
|
||||
params = {
|
||||
**{k: v for k, v in call.data.items() if k not in EVENT_TIME_FIELDS},
|
||||
EVENT_START: start,
|
||||
EVENT_END: end,
|
||||
}
|
||||
await entity.async_create_event(**params)
|
||||
|
||||
|
||||
async def async_get_events_service(
|
||||
calendar: CalendarEntity, service_call: ServiceCall
|
||||
) -> ServiceResponse:
|
||||
"""List events on a calendar during a time range."""
|
||||
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, dt_util.as_local(start), dt_util.as_local(end)
|
||||
)
|
||||
return {
|
||||
"events": [
|
||||
dataclasses.asdict(event, dict_factory=list_events_dict_factory)
|
||||
for event in calendar_event_list
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ if TYPE_CHECKING:
|
||||
from . import CalendarEntity
|
||||
|
||||
DOMAIN: Final = "calendar"
|
||||
|
||||
CREATE_EVENT_SERVICE: Final = "create_event"
|
||||
SERVICE_GET_EVENTS: Final = "get_events"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ from homeassistant.helpers.llm import (
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import SERVICE_GET_EVENTS
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, SERVICE_GET_EVENTS
|
||||
|
||||
|
||||
class CalendarGetEventsTool(Tool):
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Services for the calendar integration."""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import probatio
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CREATE_EVENT_SERVICE,
|
||||
DATA_COMPONENT,
|
||||
EVENT_DESCRIPTION,
|
||||
EVENT_DURATION,
|
||||
EVENT_END,
|
||||
EVENT_END_DATE,
|
||||
EVENT_END_DATETIME,
|
||||
EVENT_IN,
|
||||
EVENT_IN_DAYS,
|
||||
EVENT_IN_WEEKS,
|
||||
EVENT_LOCATION,
|
||||
EVENT_START,
|
||||
EVENT_START_DATE,
|
||||
EVENT_START_DATETIME,
|
||||
EVENT_SUMMARY,
|
||||
EVENT_TIME_FIELDS,
|
||||
EVENT_TYPES,
|
||||
SERVICE_GET_EVENTS,
|
||||
CalendarEntityFeature,
|
||||
)
|
||||
from .helper import (
|
||||
MIN_NEW_EVENT_DURATION,
|
||||
as_local_timezone,
|
||||
has_consistent_timezone,
|
||||
has_min_duration,
|
||||
has_positive_interval,
|
||||
list_events_dict_factory,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CalendarEntity
|
||||
|
||||
CREATE_EVENT_SCHEMA = probatio.All(
|
||||
cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
|
||||
cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
probatio.Required(EVENT_SUMMARY): cv.string,
|
||||
probatio.Optional(EVENT_DESCRIPTION, default=""): cv.string,
|
||||
probatio.Optional(EVENT_LOCATION): cv.string,
|
||||
probatio.Inclusive(
|
||||
EVENT_START_DATE, "dates", "Start and end dates must both be specified"
|
||||
): cv.date,
|
||||
probatio.Inclusive(
|
||||
EVENT_END_DATE, "dates", "Start and end dates must both be specified"
|
||||
): cv.date,
|
||||
probatio.Inclusive(
|
||||
EVENT_START_DATETIME,
|
||||
"datetimes",
|
||||
"Start and end datetimes must both be specified",
|
||||
): cv.datetime,
|
||||
probatio.Inclusive(
|
||||
EVENT_END_DATETIME,
|
||||
"datetimes",
|
||||
"Start and end datetimes must both be specified",
|
||||
): cv.datetime,
|
||||
probatio.Optional(EVENT_IN): probatio.Schema(
|
||||
{
|
||||
probatio.Exclusive(EVENT_IN_DAYS, EVENT_TYPES): cv.positive_int,
|
||||
probatio.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
||||
as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
||||
has_min_duration(EVENT_START_DATE, EVENT_END_DATE, MIN_NEW_EVENT_DURATION),
|
||||
has_min_duration(EVENT_START_DATETIME, EVENT_END_DATETIME, MIN_NEW_EVENT_DURATION),
|
||||
)
|
||||
|
||||
|
||||
SERVICE_GET_EVENTS_SCHEMA: Final = probatio.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(
|
||||
{
|
||||
probatio.Optional(EVENT_START_DATETIME): cv.datetime,
|
||||
probatio.Optional(EVENT_END_DATETIME): cv.datetime,
|
||||
probatio.Optional(EVENT_DURATION): probatio.All(
|
||||
cv.time_period, cv.positive_timedelta
|
||||
),
|
||||
}
|
||||
),
|
||||
has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
|
||||
)
|
||||
|
||||
|
||||
def _validate_timespan(
|
||||
values: dict[str, Any],
|
||||
) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]:
|
||||
"""Parse a create event service call.
|
||||
|
||||
Convert the args for a create event entity call.
|
||||
This converts the input service arguments into a
|
||||
`start` and `end` date or date time. This exists because
|
||||
service calls use `start_date` and `start_date_time`
|
||||
whereas the normal entity methods can take either a
|
||||
`datetime` or `date` as a single `start` argument.
|
||||
It also handles the other service call variations like "in days" as well.
|
||||
"""
|
||||
|
||||
if event_in := values.get(EVENT_IN):
|
||||
days = event_in.get(EVENT_IN_DAYS, 7 * event_in.get(EVENT_IN_WEEKS, 0))
|
||||
today = dt_util.now().date()
|
||||
return (
|
||||
today + datetime.timedelta(days=days),
|
||||
today + datetime.timedelta(days=days + 1),
|
||||
)
|
||||
|
||||
if EVENT_START_DATE in values and EVENT_END_DATE in values:
|
||||
return (values[EVENT_START_DATE], values[EVENT_END_DATE])
|
||||
|
||||
if EVENT_START_DATETIME in values and EVENT_END_DATETIME in values:
|
||||
return (values[EVENT_START_DATETIME], values[EVENT_END_DATETIME])
|
||||
|
||||
raise ValueError("Missing required fields to set start or end date/datetime")
|
||||
|
||||
|
||||
async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None:
|
||||
"""Add a new event to calendar."""
|
||||
# Convert parameters to format used by async_create_event
|
||||
(start, end) = _validate_timespan(call.data)
|
||||
params = {
|
||||
**{k: v for k, v in call.data.items() if k not in EVENT_TIME_FIELDS},
|
||||
EVENT_START: start,
|
||||
EVENT_END: end,
|
||||
}
|
||||
await entity.async_create_event(**params)
|
||||
|
||||
|
||||
async def async_get_events_service(
|
||||
calendar: CalendarEntity, service_call: ServiceCall
|
||||
) -> ServiceResponse:
|
||||
"""List events on a calendar during a time range."""
|
||||
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, dt_util.as_local(start), dt_util.as_local(end)
|
||||
)
|
||||
return {
|
||||
"events": [
|
||||
dataclasses.asdict(event, dict_factory=list_events_dict_factory)
|
||||
for event in calendar_event_list
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the calendar services."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
|
||||
component.async_register_entity_service(
|
||||
CREATE_EVENT_SERVICE,
|
||||
CREATE_EVENT_SCHEMA,
|
||||
async_create_event,
|
||||
required_features=[CalendarEntityFeature.CREATE_EVENT],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_GET_EVENTS,
|
||||
SERVICE_GET_EVENTS_SCHEMA,
|
||||
async_get_events_service,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from homeassistant.components import calendar, llm as llm_component
|
||||
from homeassistant.components.calendar import llm as calendar_llm
|
||||
from homeassistant.components.calendar.services import SERVICE_GET_EVENTS_SCHEMA
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
|
||||
from homeassistant.core import Context, HomeAssistant, SupportsResponse
|
||||
from homeassistant.helpers import entity_registry as er, llm
|
||||
@@ -72,7 +73,7 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None:
|
||||
hass,
|
||||
domain=calendar.DOMAIN,
|
||||
service=calendar.SERVICE_GET_EVENTS,
|
||||
schema=calendar.SERVICE_GET_EVENTS_SCHEMA,
|
||||
schema=SERVICE_GET_EVENTS_SCHEMA,
|
||||
response={
|
||||
ENTITY_ID: {
|
||||
"events": [
|
||||
|
||||
Reference in New Issue
Block a user