diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index be909a02ea5bba..8e64fdecefd760 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -3,15 +3,20 @@ from __future__ import annotations from datetime import datetime +from functools import partial import logging +from typing import Any import caldav +from caldav.lib.error import DAVError +import requests import voluptuous as vol from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, PLATFORM_SCHEMA as CALENDAR_PLATFORM_SCHEMA, CalendarEntity, + CalendarEntityFeature, CalendarEvent, is_offset_reached, ) @@ -23,6 +28,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( @@ -175,6 +181,8 @@ async def async_setup_entry( class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity): """A device for getting the next Task from a WebDav Calendar.""" + _attr_supported_features = CalendarEntityFeature.CREATE_EVENT + def __init__( self, name: str | None, @@ -203,6 +211,31 @@ async def async_get_events( """Get all events in a specific time frame.""" return await self.coordinator.async_get_events(hass, start_date, end_date) + async def async_create_event(self, **kwargs: Any) -> None: + """Create a new event in the calendar.""" + _LOGGER.debug("Event: %s", kwargs) + + item_data: dict[str, Any] = { + "summary": kwargs["summary"], + "dtstart": kwargs["dtstart"], + "dtend": kwargs["dtend"], + } + if description := kwargs.get("description"): + item_data["description"] = description + if location := kwargs.get("location"): + item_data["location"] = location + if rrule := kwargs.get("rrule"): + item_data["rrule"] = rrule + + _LOGGER.debug("ICS data %s", item_data) + + try: + await self.hass.async_add_executor_job( + partial(self.coordinator.calendar.add_event, **item_data), + ) + except (requests.ConnectionError, DAVError) as err: + raise HomeAssistantError(f"CalDAV save error: {err}") from err + @callback def _handle_coordinator_update(self) -> None: """Update event data.""" diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index b9fc230e749fff..edfd1d7cb46500 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -22,6 +22,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.20.0"], + "requirements": ["aiohomeconnect==0.22.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml index d46ed69f0d663e..84ad52db45028e 100644 --- a/homeassistant/components/onewire/quality_scale.yaml +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -115,12 +115,8 @@ rules: comment: Under review ## Platinum - async-dependency: - status: todo - comment: The dependency is not async + async-dependency: done inject-websession: status: exempt comment: No websession - strict-typing: - status: todo - comment: The dependency is not typed + strict-typing: done diff --git a/requirements_all.txt b/requirements_all.txt index 57d5c0fe903f2a..215aa00b4adeef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -277,7 +277,7 @@ aioharmony==0.5.3;python_version<'3.14' aiohasupervisor==0.3.3 # homeassistant.components.home_connect -aiohomeconnect==0.20.0 +aiohomeconnect==0.22.0 # homeassistant.components.homekit_controller aiohomekit==3.2.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74f9f2fff0c1f6..fef4675fbff431 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -262,7 +262,7 @@ aioharmony==0.5.3;python_version<'3.14' aiohasupervisor==0.3.3 # homeassistant.components.home_connect -aiohomeconnect==0.20.0 +aiohomeconnect==0.22.0 # homeassistant.components.homekit_controller aiohomekit==3.2.20 diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index e1a681e12fecc4..cf44e5b0414453 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -5,12 +5,14 @@ from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, Mock +import zoneinfo from caldav.objects import Event from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.calendar import CalendarEntityFeature from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -455,6 +457,7 @@ async def test_ongoing_event( "end_time": "2017-11-27 18:00:00", "location": "Hamburg", "description": "Surprisingly rainy", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -479,6 +482,7 @@ async def test_just_ended_event( "end_time": "2017-11-27 18:00:00", "location": "Hamburg", "description": "Surprisingly rainy", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -503,6 +507,7 @@ async def test_ongoing_event_different_tz( "description": "Sunny day", "end_time": "2017-11-27 17:30:00", "location": "San Francisco", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -527,6 +532,7 @@ async def test_ongoing_floating_event_returned( "end_time": "2017-11-27 20:00:00", "location": "Hamburg", "description": "What a day", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -551,6 +557,7 @@ async def test_ongoing_event_with_offset( "end_time": "2017-11-27 11:00:00", "location": "Hamburg", "description": "Surprisingly shiny", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -591,6 +598,7 @@ async def test_matching_filter( "end_time": "2017-11-27 18:00:00", "location": "Hamburg", "description": "Surprisingly rainy", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -632,6 +640,7 @@ async def test_matching_filter_real_regexp( "end_time": "2017-11-27 18:00:00", "location": "Hamburg", "description": "Surprisingly rainy", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -664,6 +673,7 @@ async def test_filter_matching_past_event( assert dict(state.attributes) == { "friendly_name": CALENDAR_NAME, "offset_reached": False, + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -695,6 +705,7 @@ async def test_no_result_with_filtering( assert dict(state.attributes) == { "friendly_name": CALENDAR_NAME, "offset_reached": False, + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -749,6 +760,7 @@ async def test_all_day_event( "end_time": "2017-11-28 00:00:00", "location": "Hamburg", "description": "What a beautiful day", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -773,6 +785,7 @@ async def test_event_rrule( "end_time": "2017-11-27 22:30:00", "location": "Hamburg", "description": "Every day for a while", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -797,6 +810,7 @@ async def test_event_rrule_ongoing( "end_time": "2017-11-27 22:30:00", "location": "Hamburg", "description": "Every day for a while", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -821,6 +835,7 @@ async def test_event_rrule_duration( "end_time": "2017-11-27 23:30:00", "location": "Hamburg", "description": "Every day for a while as well", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -845,6 +860,7 @@ async def test_event_rrule_duration_ongoing( "end_time": "2017-11-27 23:30:00", "location": "Hamburg", "description": "Every day for a while as well", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -869,6 +885,7 @@ async def test_event_rrule_endless( "end_time": "2017-11-27 23:59:59", "location": "Hamburg", "description": "Every day forever", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -925,6 +942,7 @@ async def test_event_rrule_all_day_early( "end_time": "2016-12-02 00:00:00", "location": "Hamburg", "description": "Groundhog Day", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -949,6 +967,7 @@ async def test_event_rrule_hourly_on_first( "end_time": "2015-11-27 00:30:00", "location": "Hamburg", "description": "The bell tolls for thee", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -973,6 +992,7 @@ async def test_event_rrule_hourly_on_last( "end_time": "2015-11-27 11:30:00", "location": "Hamburg", "description": "The bell tolls for thee", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -1105,6 +1125,7 @@ async def test_setup_config_entry( "end_time": "2017-11-28 00:00:00", "location": "Hamburg", "description": "What a beautiful day", + "supported_features": CalendarEntityFeature.CREATE_EVENT, } @@ -1140,3 +1161,103 @@ async def test_config_entry_supported_components( # No entity created when no components exist state = hass.states.get("calendar.calendar_4") assert not state + + +@pytest.mark.parametrize("tz", [UTC]) +@pytest.mark.parametrize( + ("service_data", "expected_ics_fields"), + [ + # Basic event with all fields + ( + { + "summary": "Test Event", + "start_date_time": "2025-08-06T10:00:00+00:00", + "end_date_time": "2025-08-06T11:00:00+00:00", + "description": "Test Description", + "location": "Test Location", + }, + { + "description": "Test Description", + "dtend": datetime.datetime( + 2025, 8, 6, 11, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") + ), + "dtstart": datetime.datetime( + 2025, 8, 6, 10, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") + ), + "location": "Test Location", + "summary": "Test Event", + }, + ), + # Event with only required fields + ( + { + "summary": "Required Only", + "start_date_time": "2025-08-07T09:00:00+00:00", + "end_date_time": "2025-08-07T10:00:00+00:00", + }, + { + "summary": "Required Only", + "dtstart": datetime.datetime( + 2025, 8, 7, 9, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") + ), + "dtend": datetime.datetime( + 2025, 8, 7, 10, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") + ), + }, + ), + # All-day event (date only) + ( + { + "summary": "All Day Event", + "start_date": "2025-08-08", + "end_date": "2025-08-09", + }, + { + "summary": "All Day Event", + "dtstart": datetime.date(2025, 8, 8), + "dtend": datetime.date(2025, 8, 9), + }, + ), + # Event with different timezone + ( + { + "summary": "Different TZ", + "start_date_time": "2025-08-07T09:00:00+02:00", + "end_date_time": "2025-08-07T10:00:00+02:00", + }, + { + "summary": "Different TZ", + "dtstart": datetime.datetime( + 2025, 8, 7, 7, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") + ), + "dtend": datetime.datetime( + 2025, 8, 7, 8, 0, tzinfo=zoneinfo.ZoneInfo(key="UTC") + ), + }, + ), + # Rrule is not supported in API (async_call) calls. + ], +) +async def test_add_vevent( + hass: HomeAssistant, + setup_platform_cb: Callable[[], Awaitable[None]], + calendars: list[Mock], + service_data: dict, + expected_ics_fields: dict, +) -> None: + """Test adding a VEVENT to the calendar.""" + await setup_platform_cb() + + calendars[0].add_event = MagicMock(return_value=[]) + await hass.services.async_call( + "calendar", + "create_event", + service_data, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + + calendars[0].add_event.assert_called_once() + assert calendars[0].add_event.call_args + assert calendars[0].add_event.call_args[1] == expected_ics_fields