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 read-only calendar from url #126862

Closed
wants to merge 4 commits into from
Closed
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
104 changes: 93 additions & 11 deletions homeassistant/components/local_calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util import dt as dt_util

from .const import CONF_CALENDAR_NAME, DOMAIN
from .const import CONF_CALENDAR_NAME, CONF_CALENDAR_URL, CONF_SYNC_INTERVAL, DOMAIN
from .store import LocalCalendarStore

_LOGGER = logging.getLogger(__name__)

PRODID = "-//homeassistant.io//local_calendar 1.0//EN"
DEFAULT_SYNC_INTERVAL = timedelta(days=1)
MIN_SYNC_INTERVAL = timedelta(hours=1)


async def async_setup_entry(
Expand All @@ -48,21 +52,34 @@
IcsCalendarStream.calendar_from_ics, ics
)
calendar.prodid = PRODID

name = config_entry.data[CONF_CALENDAR_NAME]
entity = LocalCalendarEntity(store, calendar, name, unique_id=config_entry.entry_id)
url = config_entry.data.get(CONF_CALENDAR_URL)
if url:
update_interval = config_entry.data.get(CONF_SYNC_INTERVAL)
if update_interval:
update_interval = timedelta(**update_interval)

Check warning on line 60 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L58-L60

Added lines #L58 - L60 were not covered by tests
else:
update_interval = DEFAULT_SYNC_INTERVAL
update_interval = max(update_interval, MIN_SYNC_INTERVAL)
entity: LocalCalendarEntityBase = RemoteCalendarEntity(

Check warning on line 64 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L62-L64

Added lines #L62 - L64 were not covered by tests
store,
calendar,
name,
unique_id=config_entry.entry_id,
url=url,
update_interval=update_interval,
)
else:
entity = LocalCalendarEntity(
store, calendar, name, unique_id=config_entry.entry_id
)
async_add_entities([entity], True)


class LocalCalendarEntity(CalendarEntity):
"""A calendar entity backed by a local iCalendar file."""
class LocalCalendarEntityBase(CalendarEntity):
"""Class for a read-only calendar entity backed by a local iCalendar file."""

_attr_has_entity_name = True
_attr_supported_features = (
CalendarEntityFeature.CREATE_EVENT
| CalendarEntityFeature.DELETE_EVENT
| CalendarEntityFeature.UPDATE_EVENT
)

def __init__(
self,
Expand All @@ -71,7 +88,7 @@
name: str,
unique_id: str,
) -> None:
"""Initialize LocalCalendarEntity."""
"""Initialize LocalCalendarEntityBase."""
self._store = store
self._calendar = calendar
self._event: CalendarEvent | None = None
Expand Down Expand Up @@ -107,6 +124,71 @@
content = IcsCalendarStream.calendar_to_ics(self._calendar)
await self._store.async_store(content)


class RemoteCalendarEntity(LocalCalendarEntityBase):
"""A read-only calendar that we get and refresh from a url."""

def __init__(
self,
store: LocalCalendarStore,
calendar: Calendar,
name: str,
unique_id: str,
url: str,
update_interval: timedelta,
) -> None:
"""Initialize a remote read-only calendar."""
super().__init__(store, calendar, name, unique_id)
self._url = url
self._client = None
self._track_fetch = None
self._etag = None
self._update_interval = update_interval

Check warning on line 146 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L141-L146

Added lines #L141 - L146 were not covered by tests

async def _fetch_calendar_and_update(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

I imagined this working more like a sync where we fetch the url, writing down to the local filesystem store, but from there the local calendar integration works like normal and is able to serve from the local filesystem store. This means it continues to work offline and truly is still a local calendar. I think it could fetch and write to the store, then work like normal (but still readonly).

I think this could be a good way to think about business logic separation for the "Store" class and fetching/reading/writing. (Right now there is a "store" for a remote calendar entity and it is ambiguous what its for so something needs to be addressed here)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understand your proposal and I agree with it. It makes a lot of sense. I will implement the changes and tests and ask for a second review.

Thank you very much for your time

headers = {}
if self._etag:
headers["If-None-Match"] = self._etag
res = await self._client.get(self._url, headers=headers)
if res.status_code == 304: # Not modified
return
res.raise_for_status()
self._etag = res.headers.get("ETag")
self._calendar = await self.hass.async_add_executor_job(

Check warning on line 157 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L149-L157

Added lines #L149 - L157 were not covered by tests
IcsCalendarStream.calendar_from_ics, res.text
)
self._calendar.prodid = PRODID
content = await self.hass.async_add_executor_job(

Check warning on line 161 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L160-L161

Added lines #L160 - L161 were not covered by tests
IcsCalendarStream.calendar_to_ics, self._calendar
)
await self._store.async_store(content)
await self.async_update_ha_state(force_refresh=True)

Check warning on line 165 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L164-L165

Added lines #L164 - L165 were not covered by tests

async def async_added_to_hass(self):
"""Once initialized, get the calendar, and schedule future updates."""
self._client = get_async_client(self.hass)
self.hass.loop.create_task(self._fetch_calendar_and_update())
self._track_fetch = async_track_time_interval(

Check warning on line 171 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L169-L171

Added lines #L169 - L171 were not covered by tests
self.hass,
lambda now: self.hass.loop.create_task(self._fetch_calendar_and_update()),
self._update_interval,
)

async def async_will_remove_from_hass(self):
"""If the entity is removed, we do not need to keep fetching the calendar."""
if self._track_fetch is not None:
self._track_fetch()

Check warning on line 180 in homeassistant/components/local_calendar/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/calendar.py#L179-L180

Added lines #L179 - L180 were not covered by tests


class LocalCalendarEntity(LocalCalendarEntityBase):
"""A calendar entity backed by a local iCalendar file."""

_attr_supported_features = (
CalendarEntityFeature.CREATE_EVENT
| CalendarEntityFeature.DELETE_EVENT
| CalendarEntityFeature.UPDATE_EVENT
)

async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar."""
event = _parse_event(kwargs)
Expand Down
53 changes: 52 additions & 1 deletion homeassistant/components/local_calendar/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,44 @@

from __future__ import annotations

import logging
from typing import Any

import httpx
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
DurationSelector,
DurationSelectorConfig,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.util import slugify

from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN
from .const import (
CONF_CALENDAR_NAME,
CONF_CALENDAR_URL,
CONF_STORAGE_KEY,
CONF_SYNC_INTERVAL,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CALENDAR_NAME): str,
vol.Optional(CONF_CALENDAR_URL): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_SYNC_INTERVAL): DurationSelector(
Copy link
Contributor

Choose a reason for hiding this comment

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

This needs to be removed from the config flow. The design standards for how to do updates are here https://developers.home-assistant.io/docs/integration_fetching_data/ -- This is something generic that applies to any entity and not something we want to add to the integration specific configuration flow.

The way we'll want to handle this is:

  • Pick a reasonable default for how often to refresh this (not too often)
  • Users can add their own automation and a schedule using an action call to homeassistant.update_entity to refresh

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will follow those instructions. Thank you. I have a lot to learn about coding for home assistant integrations.

Since each remote calendar entity has its own URL, I first thought I should go for Separate polling for each individual entity, and use the async_update() method for polling the data.

However I believe the async_update() method is currently called when the calendar entity needs to update the next event attribute, and I do not think we need to fetch the data that frequently. Considering this, having one coordinator class for each entity seems to make more sense. Would you agree? Suggestions are welcome.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great thanks. Very happy to have your contribution, and while there is a learning curve it is quite rewarding to contribute to home assistant so really happy to help here. You have a great start already and reviewers are happy to point out best practices that may not be easy to find.

Yeah doing the polling in each entity is fine.

OK so the calendar entity is a bit special, and so i can explain here how it works. Consider here a scenario for Google calendar: Say every 15 minutes you refresh the cloud calendar, but then a new event shows up. It starts in 5 minutes, so you also need to handle the state transition from "off" to "on". How this works is the calendar base class will handle this for you. You'd just refresh the calendar event every 15 minutes and the base class will set an additional timer based on event for 5 minutes and it will trigger another state change.

See

def async_write_ha_state(self) -> None:
for details on how this works.

So yeah doing it per entity as you said is fine.

DurationSelectorConfig(
enable_day=True, enable_millisecond=False, allow_negative=False
)
),
}
)

Expand All @@ -35,6 +61,31 @@
key = slugify(user_input[CONF_CALENDAR_NAME])
self._async_abort_entries_match({CONF_STORAGE_KEY: key})
user_input[CONF_STORAGE_KEY] = key
if url := user_input.get(CONF_CALENDAR_URL):
Copy link
Contributor

Choose a reason for hiding this comment

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

Increase test coverage for this file.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, tests and documentation are a must for the merge. I was coding this from my phone, on my live home assistant instance and I did not have access to tools to run the testsuite.

I will add the tests and a documentation pull request as well

try:
vol.Schema(vol.Url())(url)
except vol.Invalid:
return self.async_show_form(

Check warning on line 68 in homeassistant/components/local_calendar/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/config_flow.py#L65-L68

Added lines #L65 - L68 were not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

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

Restructure this method to have a single show form call, not one per exception.

step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors={CONF_CALENDAR_URL: "invalid_url"},
last_step=True,
)
client = get_async_client(self.hass)
res = await client.get(url)
try:
res.raise_for_status()
except httpx.HTTPError:
return self.async_show_form(

Check warning on line 81 in homeassistant/components/local_calendar/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/local_calendar/config_flow.py#L76-L81

Added lines #L76 - L81 were not covered by tests
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors={CONF_CALENDAR_URL: "cannot_connect"},
last_step=True,
)
return self.async_create_entry(
title=user_input[CONF_CALENDAR_NAME], data=user_input
)
2 changes: 2 additions & 0 deletions homeassistant/components/local_calendar/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
DOMAIN = "local_calendar"

CONF_CALENDAR_NAME = "calendar_name"
CONF_CALENDAR_URL = "calendar_url"
CONF_STORAGE_KEY = "storage_key"
CONF_SYNC_INTERVAL = "update_interval"
8 changes: 7 additions & 1 deletion homeassistant/components/local_calendar/strings.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
{
"title": "Local Calendar",
"config": {
"error": {
"cannot_connect": "can't connect to URL",
"invalid_url": "Invalid URL"
},
"step": {
"user": {
"description": "Please choose a name for your new calendar",
"data": {
"calendar_name": "Calendar Name"
"calendar_name": "Calendar Name",
"calendar_url": "Calendar URL. If given, create a read-only calendar.",
"update_interval": "Update interval (applies to read-only calendars)"
}
}
}
Expand Down
Loading