From c50b6316bfb0aaad82d095aecf246d76e75b1742 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:36:39 +0200 Subject: [PATCH] Move calendar service registration to services module (#183589) Co-authored-by: Claude Opus 5 --- homeassistant/components/calendar/__init__.py | 155 ++------------- homeassistant/components/calendar/const.py | 3 + homeassistant/components/calendar/llm.py | 3 +- homeassistant/components/calendar/services.py | 187 ++++++++++++++++++ tests/components/calendar/test_llm.py | 3 +- 5 files changed, 205 insertions(+), 146 deletions(-) create mode 100644 homeassistant/components/calendar/services.py diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 0d1f4f0d2465..21a9d34a8523 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -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 - ] - } diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index afdb4dd321cf..9fda4bbeeeca 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -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) diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py index 949ba6bce204..20c79ea92f0b 100644 --- a/homeassistant/components/calendar/llm.py +++ b/homeassistant/components/calendar/llm.py @@ -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): diff --git a/homeassistant/components/calendar/services.py b/homeassistant/components/calendar/services.py new file mode 100644 index 000000000000..becafd7e57e3 --- /dev/null +++ b/homeassistant/components/calendar/services.py @@ -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, + ) diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py index 05361df2425d..7c6b0680976b 100644 --- a/tests/components/calendar/test_llm.py +++ b/tests/components/calendar/test_llm.py @@ -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": [