Skip to content

Commit

Permalink
Add config flow to CalDAV (#103215)
Browse files Browse the repository at this point in the history
* Initial caldav config flow with broken calendar platform

* Set up calendar entities

* Remove separate caldav entity

* Update tests after merge

* Readbility improvements

* Address lint issues

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add checking for duplicate configuration entries

* Use verify SSL as input into caldav and simplify test setup

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
  • Loading branch information
allenporter and MartinHjelmare committed Nov 3, 2023
1 parent 06c9719 commit a95aa4e
Show file tree
Hide file tree
Showing 12 changed files with 752 additions and 29 deletions.
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
)
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,
}
)


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):
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

0 comments on commit a95aa4e

Please sign in to comment.