Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add config flow to CalDAV #103215

Merged
merged 9 commits into from
Nov 3, 2023
60 changes: 60 additions & 0 deletions homeassistant/components/caldav/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,61 @@
"""The caldav component."""

import logging

import caldav
from caldav.lib.error import AuthorizationError, DAVError
import requests

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


PLATFORMS: list[Platform] = [Platform.CALENDAR]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up CalDAV from a config entry."""
hass.data.setdefault(DOMAIN, {})

client = caldav.DAVClient(
entry.data[CONF_URL],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
ssl_verify_cert=entry.data[CONF_VERIFY_SSL],
)
try:
await hass.async_add_executor_job(client.principal)
except AuthorizationError as err:
if err.reason == "Unauthorized":
raise ConfigEntryAuthFailed("Credentials error from CalDAV server") from err
# AuthorizationError can be raised if the url is incorrect or
# on some other unexpected server response.
_LOGGER.warning("Unexpected CalDAV server response: %s", err)
return False
except requests.ConnectionError as err:
raise ConfigEntryNotReady("Connection error from CalDAV server") from err
except DAVError as err:
raise ConfigEntryNotReady("CalDAV client error") from err

hass.data[DOMAIN][entry.entry_id] = client

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
70 changes: 60 additions & 10 deletions homeassistant/components/caldav/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
CalendarEvent,
is_offset_reached,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
Expand All @@ -28,6 +29,7 @@
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import CalDavUpdateCoordinator

_LOGGER = logging.getLogger(__name__)
Expand All @@ -38,6 +40,10 @@
CONF_SEARCH = "search"
CONF_DAYS = "days"

# Number of days to look ahead for next event when configured by ConfigEntry
CONFIG_ENTRY_DEFAULT_DAYS = 7

OFFSET = "!!"

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
Expand Down Expand Up @@ -106,7 +112,9 @@ def setup_platform(
include_all_day=True,
search=cust_calendar[CONF_SEARCH],
)
calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator))
calendar_devices.append(
WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True)
)

# Create a default calendar if there was no custom one for all calendars
# that support events.
Expand All @@ -131,20 +139,61 @@ def setup_platform(
include_all_day=False,
search=None,
)
calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator))
calendar_devices.append(
WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True)
)

add_entities(calendar_devices, True)


async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the CalDav calendar platform for a config entry."""
client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id]
calendars = await hass.async_add_executor_job(client.principal().calendars)
async_add_entities(
(
WebDavCalendarEntity(
calendar.name,
generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass),
CalDavUpdateCoordinator(
hass,
calendar=calendar,
days=CONFIG_ENTRY_DEFAULT_DAYS,
include_all_day=True,
search=None,
),
unique_id=f"{entry.entry_id}-{calendar.id}",
)
for calendar in calendars
if calendar.name
),
True,
)


class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity):
"""A device for getting the next Task from a WebDav Calendar."""

def __init__(self, name, entity_id, coordinator):
def __init__(
self,
name: str,
entity_id: str,
coordinator: CalDavUpdateCoordinator,
unique_id: str | None = None,
supports_offset: bool = False,
) -> None:
"""Create the WebDav Calendar Event Device."""
super().__init__(coordinator)
self.entity_id = entity_id
self._event: CalendarEvent | None = None
self._attr_name = name
if unique_id is not None:
self._attr_unique_id = unique_id
self._supports_offset = supports_offset

@property
def event(self) -> CalendarEvent | None:
Expand All @@ -161,13 +210,14 @@ async def async_get_events(
def _handle_coordinator_update(self) -> None:
"""Update event data."""
self._event = self.coordinator.data
self._attr_extra_state_attributes = {
"offset_reached": is_offset_reached(
self._event.start_datetime_local, self.coordinator.offset
)
if self._event
else False
}
if self._supports_offset:
self._attr_extra_state_attributes = {
"offset_reached": is_offset_reached(
self._event.start_datetime_local, self.coordinator.offset
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going to deprecate the is_offset_reached for the legacy entities or deprecate the legacy entities? If so, I think this is ok, but otherwise our standard nowadays is to set the state attribute to None when we don't know it for the entity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I would generally like to make it go away slowly, given its weird and hacky and triggers should be the new way to handle this. My thought was to no longer support it from the UI but preserve it for existing yaml users. We could technically support it here since its just pulled from the titles of the calendars.

I was thinking this needed to be announced when making yaml go away, but perhaps i need to start shifting the docs since I kind of punted on what to do about existing yaml for now, assuming it will be possible to deprecate in the future. (I think we may need a better solution for search still like a calendar helper to fully make it go away)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably need to at least say in the docs if it's not supported when setting up the integration from the UI.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, i've added a documentation PR (which was missing in the first place) and have addressed this.

)
if self._event
else False
}
super()._handle_coordinator_update()

async def async_added_to_hass(self) -> None:
Expand Down
127 changes: 127 additions & 0 deletions homeassistant/components/caldav/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Configuration flow for CalDav."""

from collections.abc import Mapping
import logging
from typing import Any

import caldav
from caldav.lib.error import AuthorizationError, DAVError
import requests
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Required(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD, default=""): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
}
)


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for caldav."""

VERSION = 1
_reauth_entry: config_entries.ConfigEntry | None = None

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{
CONF_URL: user_input[CONF_URL],
CONF_USERNAME: user_input[CONF_USERNAME],
}
)
if error := await self._test_connection(user_input):
allenporter marked this conversation as resolved.
Show resolved Hide resolved
errors["base"] = error
else:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)

return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

async def _test_connection(self, user_input: dict[str, Any]) -> str | None:
"""Test the connection to the CalDAV server and return an error if any."""
client = caldav.DAVClient(
user_input[CONF_URL],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
ssl_verify_cert=user_input[CONF_VERIFY_SSL],
)
try:
await self.hass.async_add_executor_job(client.principal)
except AuthorizationError as err:
_LOGGER.warning("Authorization Error connecting to CalDAV server: %s", err)
if err.reason == "Unauthorized":
return "invalid_auth"
# AuthorizationError can be raised if the url is incorrect or
# on some other unexpected server response.
return "cannot_connect"
except requests.ConnectionError as err:
_LOGGER.warning("Connection Error connecting to CalDAV server: %s", err)
return "cannot_connect"
except DAVError as err:
_LOGGER.warning("CalDAV client error: %s", err)
return "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return "unknown"
return None

async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
errors = {}
assert self._reauth_entry
if user_input is not None:
user_input = {**self._reauth_entry.data, **user_input}

if error := await self._test_connection(user_input):
errors["base"] = error
else:
self.hass.config_entries.async_update_entry(
self._reauth_entry, data=user_input
)
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_show_form(
description_placeholders={
CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME],
},
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)
5 changes: 5 additions & 0 deletions homeassistant/components/caldav/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constands for CalDAV."""

from typing import Final

DOMAIN: Final = "caldav"
1 change: 1 addition & 0 deletions homeassistant/components/caldav/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"domain": "caldav",
"name": "CalDAV",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"],
Expand Down
34 changes: 34 additions & 0 deletions homeassistant/components/caldav/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Please enter your CalDAV server credentials"
},
"reauth_confirm": {
"description": "The password for {username} is invalid.",
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"bsblan",
"bthome",
"buienradar",
"caldav",
"canary",
"cast",
"cert_expiry",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@
"caldav": {
"name": "CalDAV",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_polling"
},
"canary": {
Expand Down