Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions homeassistant/components/caldav/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/home_connect/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."]
}
8 changes: 2 additions & 6 deletions homeassistant/components/onewire/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions tests/components/caldav/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand All @@ -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,
}


Expand All @@ -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,
}


Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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
Loading