Cleanup calendar APIs and introduce a dataclass for representing events (#68843)

* Introduce data class to hold calendar event data

* Rename CalendarEventDevice to CalendarEntity

* Apply suggestions from code review

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

* Fix docstring on google calendar api conversion function.

* Update todoist to new calendar enttiy api, tested manually

* Add back old API for a legacy compatibility layer

* Add deprecation warning for old calendar APIs

* Fix deprecation warning

* Fix merge for missing summary #69520

* Add mypy typing for newly introduced classes

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-04-10 12:04:07 -07:00 committed by GitHub
parent c98d120ba0
commit f99b6004ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 341 additions and 181 deletions

View File

@ -12,9 +12,9 @@ import voluptuous as vol
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
ENTITY_ID_FORMAT, ENTITY_ID_FORMAT,
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
CalendarEventDevice, CalendarEntity,
CalendarEvent,
extract_offset, extract_offset,
get_date,
is_offset_reached, is_offset_reached,
) )
from homeassistant.const import ( from homeassistant.const import (
@ -104,7 +104,7 @@ def setup_platform(
device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}"
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
calendar_devices.append( calendar_devices.append(
WebDavCalendarEventDevice( WebDavCalendarEntity(
name, calendar, entity_id, days, True, cust_calendar[CONF_SEARCH] name, calendar, entity_id, days, True, cust_calendar[CONF_SEARCH]
) )
) )
@ -115,24 +115,24 @@ def setup_platform(
device_id = calendar.name device_id = calendar.name
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
calendar_devices.append( calendar_devices.append(
WebDavCalendarEventDevice(name, calendar, entity_id, days) WebDavCalendarEntity(name, calendar, entity_id, days)
) )
add_entities(calendar_devices, True) add_entities(calendar_devices, True)
class WebDavCalendarEventDevice(CalendarEventDevice): class WebDavCalendarEntity(CalendarEntity):
"""A device for getting the next Task from a WebDav Calendar.""" """A device for getting the next Task from a WebDav Calendar."""
def __init__(self, name, calendar, entity_id, days, all_day=False, search=None): def __init__(self, name, calendar, entity_id, days, all_day=False, search=None):
"""Create the WebDav Calendar Event Device.""" """Create the WebDav Calendar Event Device."""
self.data = WebDavCalendarData(calendar, days, all_day, search) self.data = WebDavCalendarData(calendar, days, all_day, search)
self.entity_id = entity_id self.entity_id = entity_id
self._event = None self._event: CalendarEvent | None = None
self._attr_name = name self._attr_name = name
@property @property
def event(self): def event(self) -> CalendarEvent | None:
"""Return the next upcoming event.""" """Return the next upcoming event."""
return self._event return self._event
@ -147,11 +147,11 @@ class WebDavCalendarEventDevice(CalendarEventDevice):
if event is None: if event is None:
self._event = event self._event = event
return return
(summary, offset) = extract_offset(event["summary"], OFFSET) (summary, offset) = extract_offset(event.summary, OFFSET)
event["summary"] = summary event.summary = summary
self._event = event self._event = event
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
"offset_reached": is_offset_reached(get_date(event["start"]), offset) "offset_reached": is_offset_reached(event.start_datetime_local, offset)
} }
@ -166,7 +166,9 @@ class WebDavCalendarData:
self.search = search self.search = search
self.event = None self.event = None
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
# Get event list from the current calendar # Get event list from the current calendar
vevent_list = await hass.async_add_executor_job( vevent_list = await hass.async_add_executor_job(
@ -180,22 +182,15 @@ class WebDavCalendarData:
vevent = event.instance.vevent vevent = event.instance.vevent
if not self.is_matching(vevent, self.search): if not self.is_matching(vevent, self.search):
continue continue
uid = None event_list.append(
if hasattr(vevent, "uid"): CalendarEvent(
uid = vevent.uid.value summary=vevent.summary.value,
data = { start=vevent.dtstart.value,
"uid": uid, end=self.get_end_date(vevent),
"summary": vevent.summary.value, location=self.get_attr_value(vevent, "location"),
"start": self.get_hass_date(vevent.dtstart.value), description=self.get_attr_value(vevent, "description"),
"end": self.get_hass_date(self.get_end_date(vevent)), )
"location": self.get_attr_value(vevent, "location"), )
"description": self.get_attr_value(vevent, "description"),
}
data["start"] = get_date(data["start"]).isoformat()
data["end"] = get_date(data["end"]).isoformat()
event_list.append(data)
return event_list return event_list
@ -269,13 +264,13 @@ class WebDavCalendarData:
return return
# Populate the entity attributes with the event values # Populate the entity attributes with the event values
self.event = { self.event = CalendarEvent(
"summary": vevent.summary.value, summary=vevent.summary.value,
"start": self.get_hass_date(vevent.dtstart.value), start=vevent.dtstart.value,
"end": self.get_hass_date(self.get_end_date(vevent)), end=self.get_end_date(vevent),
"location": self.get_attr_value(vevent, "location"), location=self.get_attr_value(vevent, "location"),
"description": self.get_attr_value(vevent, "description"), description=self.get_attr_value(vevent, "description"),
} )
@staticmethod @staticmethod
def is_matching(vevent, search): def is_matching(vevent, search):
@ -305,14 +300,6 @@ class WebDavCalendarData:
WebDavCalendarData.get_end_date(vevent) WebDavCalendarData.get_end_date(vevent)
) )
@staticmethod
def get_hass_date(obj):
"""Return if the event matches."""
if isinstance(obj, datetime):
return {"dateTime": obj.isoformat()}
return {"date": obj.isoformat()}
@staticmethod @staticmethod
def to_datetime(obj): def to_datetime(obj):
"""Return a datetime.""" """Return a datetime."""

View File

@ -1,6 +1,7 @@
"""Support for Google Calendar event device sensors.""" """Support for Google Calendar event device sensors."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import datetime import datetime
from http import HTTPStatus from http import HTTPStatus
import logging import logging
@ -73,6 +74,48 @@ def get_date(date: dict[str, Any]) -> datetime.datetime:
return dt.as_local(parsed_datetime) return dt.as_local(parsed_datetime)
@dataclass
class CalendarEvent:
"""An event on a calendar."""
start: datetime.date | datetime.datetime
end: datetime.date | datetime.datetime
summary: str
description: str | None = None
location: str | None = None
@property
def start_datetime_local(self) -> datetime.datetime:
"""Return event start time as a local datetime."""
return _get_datetime_local(self.start)
@property
def end_datetime_local(self) -> datetime.datetime:
"""Return event end time as a local datetime."""
return _get_datetime_local(self.end)
@property
def all_day(self) -> bool:
"""Return true if the event is an all day event."""
return not isinstance(self.start, datetime.datetime)
def _get_datetime_local(
dt_or_d: datetime.datetime | datetime.date,
) -> datetime.datetime:
"""Convert a calendar event date/datetime to a datetime if needed."""
if isinstance(dt_or_d, datetime.datetime):
return dt.as_local(dt_or_d)
return dt.start_of_local_day(dt_or_d)
def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]:
"""Convert a calendar event date/datetime to a datetime if needed."""
if isinstance(dt_or_d, datetime.datetime):
return {"dateTime": dt.as_local(dt_or_d).isoformat()}
return {"date": dt_or_d.isoformat()}
def normalize_event(event: dict[str, Any]) -> dict[str, Any]: def normalize_event(event: dict[str, Any]) -> dict[str, Any]:
"""Normalize a calendar event.""" """Normalize a calendar event."""
normalized_event: dict[str, Any] = {} normalized_event: dict[str, Any] = {}
@ -132,7 +175,15 @@ def is_offset_reached(
class CalendarEventDevice(Entity): class CalendarEventDevice(Entity):
"""Base class for calendar event entities.""" """Legacy API for calendar event entities."""
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Print deprecation warning."""
super().__init_subclass__(**kwargs)
_LOGGER.warning(
"CalendarEventDevice is deprecated, modify %s to extend CalendarEntity",
cls.__name__,
)
@property @property
def event(self) -> dict[str, Any] | None: def event(self) -> dict[str, Any] | None:
@ -143,6 +194,7 @@ class CalendarEventDevice(Entity):
@property @property
def state_attributes(self) -> dict[str, Any] | None: def state_attributes(self) -> dict[str, Any] | None:
"""Return the entity state attributes.""" """Return the entity state attributes."""
if (event := self.event) is None: if (event := self.event) is None:
return None return None
@ -186,6 +238,53 @@ class CalendarEventDevice(Entity):
raise NotImplementedError() raise NotImplementedError()
class CalendarEntity(Entity):
"""Base class for calendar event entities."""
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
raise NotImplementedError()
@final
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return the entity state attributes."""
if (event := self.event) is None:
return None
return {
"message": event.summary,
"all_day": event.all_day,
"start_time": event.start_datetime_local.strftime(DATE_STR_FORMAT),
"end_time": event.end_datetime_local.strftime(DATE_STR_FORMAT),
"location": event.location if event.location else "",
"description": event.description if event.description else "",
}
@property
def state(self) -> str | None:
"""Return the state of the calendar event."""
if (event := self.event) is None:
return STATE_OFF
now = dt.now()
if event.start_datetime_local <= now < event.end_datetime_local:
return STATE_ON
return STATE_OFF
async def async_get_events(
self,
hass: HomeAssistant,
start_date: datetime.datetime,
end_date: datetime.datetime,
) -> list[CalendarEvent]:
"""Return calendar events within a datetime range."""
raise NotImplementedError()
class CalendarEventView(http.HomeAssistantView): class CalendarEventView(http.HomeAssistantView):
"""View to retrieve calendar content.""" """View to retrieve calendar content."""
@ -203,7 +302,6 @@ class CalendarEventView(http.HomeAssistantView):
end = request.query.get("end") end = request.query.get("end")
if start is None or end is None or entity is None: if start is None or end is None or entity is None:
return web.Response(status=HTTPStatus.BAD_REQUEST) return web.Response(status=HTTPStatus.BAD_REQUEST)
assert isinstance(entity, CalendarEventDevice)
try: try:
start_date = dt.parse_datetime(start) start_date = dt.parse_datetime(start)
end_date = dt.parse_datetime(end) end_date = dt.parse_datetime(end)
@ -211,10 +309,30 @@ class CalendarEventView(http.HomeAssistantView):
return web.Response(status=HTTPStatus.BAD_REQUEST) return web.Response(status=HTTPStatus.BAD_REQUEST)
if start_date is None or end_date is None: if start_date is None or end_date is None:
return web.Response(status=HTTPStatus.BAD_REQUEST) return web.Response(status=HTTPStatus.BAD_REQUEST)
event_list = await entity.async_get_events(
# Compatibility shim for old API
if isinstance(entity, CalendarEventDevice):
event_list = await entity.async_get_events(
request.app["hass"], start_date, end_date
)
return self.json(event_list)
if not isinstance(entity, CalendarEntity):
return web.Response(status=HTTPStatus.BAD_REQUEST)
calendar_event_list = await entity.async_get_events(
request.app["hass"], start_date, end_date request.app["hass"], start_date, end_date
) )
return self.json(event_list) return self.json(
[
{
"summary": event.summary,
"start": _get_api_date(event.start),
"end": _get_api_date(event.end),
}
for event in calendar_event_list
]
)
class CalendarListView(http.HomeAssistantView): class CalendarListView(http.HomeAssistantView):

View File

@ -2,8 +2,14 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import datetime
from homeassistant.components.calendar import CalendarEventDevice, get_date from homeassistant.components.calendar import (
CalendarEntity,
CalendarEvent,
CalendarEventDevice,
get_date,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -17,37 +23,66 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Demo Calendar platform.""" """Set up the Demo Calendar platform."""
calendar_data_future = DemoGoogleCalendarDataFuture()
calendar_data_current = DemoGoogleCalendarDataCurrent()
add_entities( add_entities(
[ [
DemoGoogleCalendar(hass, calendar_data_future, "Calendar 1"), DemoCalendar(calendar_data_future(), "Calendar 1"),
DemoGoogleCalendar(hass, calendar_data_current, "Calendar 2"), DemoCalendar(calendar_data_current(), "Calendar 2"),
LegacyDemoCalendar("Calendar 3"),
] ]
) )
class DemoGoogleCalendarData: def calendar_data_future() -> CalendarEvent:
"""Representation of a Demo Calendar for a future event."""
one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30)
return CalendarEvent(
start=one_hour_from_now,
end=one_hour_from_now + datetime.timedelta(minutes=60),
summary="Future Event",
)
def calendar_data_current() -> CalendarEvent:
"""Representation of a Demo Calendar for a current event."""
middle_of_event = dt_util.now() - datetime.timedelta(minutes=30)
return CalendarEvent(
start=middle_of_event,
end=middle_of_event + datetime.timedelta(minutes=60),
summary="Current Event",
)
class DemoCalendar(CalendarEntity):
"""Representation of a Demo Calendar element.""" """Representation of a Demo Calendar element."""
event = None def __init__(self, event: CalendarEvent, name: str) -> None:
"""Initialize demo calendar."""
self._event = event
self._name = name
async def async_get_events(self, hass, start_date, end_date): @property
"""Get all events in a specific time frame.""" def event(self) -> CalendarEvent:
event = copy.copy(self.event) """Return the next upcoming event."""
event["title"] = event["summary"] return self._event
event["start"] = get_date(event["start"]).isoformat()
event["end"] = get_date(event["end"]).isoformat() @property
return [event] def name(self) -> str:
"""Return the name of the entity."""
return self._name
async def async_get_events(self, hass, start_date, end_date) -> list[CalendarEvent]:
"""Return calendar events within a datetime range."""
return [self._event]
class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): class LegacyDemoCalendar(CalendarEventDevice):
"""Representation of a Demo Calendar for a future event.""" """Calendar for exercising shim API."""
def __init__(self): def __init__(self, name):
"""Set the event to a future event.""" """Initialize demo calendar."""
self._name = name
one_hour_from_now = dt_util.now() + dt_util.dt.timedelta(minutes=30) one_hour_from_now = dt_util.now() + dt_util.dt.timedelta(minutes=30)
self.event = { self._event = {
"start": {"dateTime": one_hour_from_now.isoformat()}, "start": {"dateTime": one_hour_from_now.isoformat()},
"end": { "end": {
"dateTime": ( "dateTime": (
@ -57,36 +92,10 @@ class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData):
"summary": "Future Event", "summary": "Future Event",
} }
class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData):
"""Representation of a Demo Calendar for a current event."""
def __init__(self):
"""Set the event data."""
middle_of_event = dt_util.now() - dt_util.dt.timedelta(minutes=30)
self.event = {
"start": {"dateTime": middle_of_event.isoformat()},
"end": {
"dateTime": (
middle_of_event + dt_util.dt.timedelta(minutes=60)
).isoformat()
},
"summary": "Current Event",
}
class DemoGoogleCalendar(CalendarEventDevice):
"""Representation of a Demo Calendar element."""
def __init__(self, hass, calendar_data, name):
"""Initialize demo calendar."""
self.data = calendar_data
self._name = name
@property @property
def event(self): def event(self):
"""Return the next upcoming event.""" """Return the next upcoming event."""
return self.data.event return self._event
@property @property
def name(self): def name(self):
@ -94,5 +103,9 @@ class DemoGoogleCalendar(CalendarEventDevice):
return self._name return self._name
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(self, hass, start_date, end_date):
"""Return calendar events within a datetime range.""" """Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date) event = copy.copy(self.event)
event["title"] = event["summary"]
event["start"] = get_date(event["start"]).isoformat()
event["end"] = get_date(event["end"]).isoformat()
return [event]

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
import logging import logging
from typing import Any from typing import Any
@ -10,9 +10,9 @@ from httplib2 import ServerNotFoundError
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
ENTITY_ID_FORMAT, ENTITY_ID_FORMAT,
CalendarEventDevice, CalendarEntity,
CalendarEvent,
extract_offset, extract_offset,
get_date,
is_offset_reached, is_offset_reached,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -22,7 +22,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle from homeassistant.util import Throttle, dt
from . import ( from . import (
CONF_CAL_ID, CONF_CAL_ID,
@ -94,7 +94,7 @@ def _async_setup_entities(
entity_id = generate_entity_id( entity_id = generate_entity_id(
ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass
) )
entity = GoogleCalendarEventDevice( entity = GoogleCalendarEntity(
calendar_service, disc_info[CONF_CAL_ID], data, entity_id calendar_service, disc_info[CONF_CAL_ID], data, entity_id
) )
entities.append(entity) entities.append(entity)
@ -102,7 +102,7 @@ def _async_setup_entities(
async_add_entities(entities, True) async_add_entities(entities, True)
class GoogleCalendarEventDevice(CalendarEventDevice): class GoogleCalendarEntity(CalendarEntity):
"""A calendar event device.""" """A calendar event device."""
def __init__( def __init__(
@ -117,7 +117,7 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
self._calendar_id = calendar_id self._calendar_id = calendar_id
self._search: str | None = data.get(CONF_SEARCH) self._search: str | None = data.get(CONF_SEARCH)
self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False) self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False)
self._event: dict[str, Any] | None = None self._event: CalendarEvent | None = None
self._name: str = data[CONF_NAME] self._name: str = data[CONF_NAME]
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
self._offset_reached = False self._offset_reached = False
@ -129,7 +129,7 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
return {"offset_reached": self._offset_reached} return {"offset_reached": self._offset_reached}
@property @property
def event(self) -> dict[str, Any] | None: def event(self) -> CalendarEvent | None:
"""Return the next upcoming event.""" """Return the next upcoming event."""
return self._event return self._event
@ -146,7 +146,7 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
async def async_get_events( async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[dict[str, Any]]: ) -> list[CalendarEvent]:
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
event_list: list[dict[str, Any]] = [] event_list: list[dict[str, Any]] = []
page_token: str | None = None page_token: str | None = None
@ -166,7 +166,8 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
event_list.extend(filter(self._event_filter, items)) event_list.extend(filter(self._event_filter, items))
if not page_token: if not page_token:
break break
return event_list
return [_get_calendar_event(event) for event in event_list]
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None: async def async_update(self) -> None:
@ -181,12 +182,35 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
# Pick the first visible event and apply offset calculations. # Pick the first visible event and apply offset calculations.
valid_items = filter(self._event_filter, items) valid_items = filter(self._event_filter, items)
self._event = copy.deepcopy(next(valid_items, None)) event = copy.deepcopy(next(valid_items, None))
if self._event: if event:
(summary, offset) = extract_offset( (summary, offset) = extract_offset(event.get("summary", ""), self._offset)
self._event.get("summary", ""), self._offset event["summary"] = summary
) self._event = _get_calendar_event(event)
self._event["summary"] = summary
self._offset_reached = is_offset_reached( self._offset_reached = is_offset_reached(
get_date(self._event["start"]), offset self._event.start_datetime_local, offset
) )
else:
self._event = None
def _get_date_or_datetime(date_dict: dict[str, str]) -> datetime | date:
"""Convert a google calendar API response to a datetime or date object."""
if "date" in date_dict:
parsed_date = dt.parse_date(date_dict["date"])
assert parsed_date
return parsed_date
parsed_datetime = dt.parse_datetime(date_dict["dateTime"])
assert parsed_datetime
return parsed_datetime
def _get_calendar_event(event: dict[str, Any]) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
return CalendarEvent(
summary=event["summary"],
start=_get_date_or_datetime(event["start"]),
end=_get_date_or_datetime(event["end"]),
description=event.get("description"),
location=event.get("location"),
)

View File

@ -1,18 +1,21 @@
"""Support for Todoist task management (https://todoist.com).""" """Support for Todoist task management (https://todoist.com)."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
import logging import logging
from todoist.api import TodoistAPI from todoist.api import TodoistAPI
import voluptuous as vol import voluptuous as vol
from homeassistant.components.calendar import PLATFORM_SCHEMA, CalendarEventDevice from homeassistant.components.calendar import (
PLATFORM_SCHEMA,
CalendarEntity,
CalendarEvent,
)
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt from homeassistant.util import dt
@ -28,7 +31,6 @@ from .const import (
CONF_PROJECT_LABEL_WHITELIST, CONF_PROJECT_LABEL_WHITELIST,
CONF_PROJECT_WHITELIST, CONF_PROJECT_WHITELIST,
CONTENT, CONTENT,
DATETIME,
DESCRIPTION, DESCRIPTION,
DOMAIN, DOMAIN,
DUE, DUE,
@ -102,7 +104,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
} }
) )
SCAN_INTERVAL = timedelta(minutes=15) SCAN_INTERVAL = timedelta(minutes=1)
def setup_platform( def setup_platform(
@ -136,7 +138,7 @@ def setup_platform(
# Project is an object, not a dict! # Project is an object, not a dict!
# Because of that, we convert what we need to a dict. # Because of that, we convert what we need to a dict.
project_data = {CONF_NAME: project[NAME], CONF_ID: project[ID]} project_data = {CONF_NAME: project[NAME], CONF_ID: project[ID]}
project_devices.append(TodoistProjectDevice(hass, project_data, labels, api)) project_devices.append(TodoistProjectEntity(hass, project_data, labels, api))
# Cache the names so we can easily look up name->ID. # Cache the names so we can easily look up name->ID.
project_id_lookup[project[NAME].lower()] = project[ID] project_id_lookup[project[NAME].lower()] = project[ID]
@ -166,7 +168,7 @@ def setup_platform(
# Create the custom project and add it to the devices array. # Create the custom project and add it to the devices array.
project_devices.append( project_devices.append(
TodoistProjectDevice( TodoistProjectEntity(
hass, hass,
project, project,
labels, labels,
@ -176,7 +178,6 @@ def setup_platform(
project_id_filter, project_id_filter,
) )
) )
add_entities(project_devices) add_entities(project_devices)
def handle_new_task(call: ServiceCall) -> None: def handle_new_task(call: ServiceCall) -> None:
@ -271,7 +272,7 @@ def _parse_due_date(data: DueDate, timezone_offset: int) -> datetime | None:
return dt.as_utc(nowtime) return dt.as_utc(nowtime)
class TodoistProjectDevice(CalendarEventDevice): class TodoistProjectEntity(CalendarEntity):
"""A device for getting the next Task from a Todoist Project.""" """A device for getting the next Task from a Todoist Project."""
def __init__( def __init__(
@ -284,7 +285,7 @@ class TodoistProjectDevice(CalendarEventDevice):
whitelisted_labels=None, whitelisted_labels=None,
whitelisted_projects=None, whitelisted_projects=None,
): ):
"""Create the Todoist Calendar Event Device.""" """Create the Todoist Calendar Entity."""
self.data = TodoistProjectData( self.data = TodoistProjectData(
data, data,
labels, labels,
@ -297,9 +298,9 @@ class TodoistProjectDevice(CalendarEventDevice):
self._name = data[CONF_NAME] self._name = data[CONF_NAME]
@property @property
def event(self): def event(self) -> CalendarEvent:
"""Return the next upcoming event.""" """Return the next upcoming event."""
return self.data.event return self.data.calendar_event
@property @property
def name(self): def name(self):
@ -314,7 +315,12 @@ class TodoistProjectDevice(CalendarEventDevice):
task[SUMMARY] for task in self.data.all_project_tasks task[SUMMARY] for task in self.data.all_project_tasks
] ]
async def async_get_events(self, hass, start_date, end_date): async def async_get_events(
self,
hass: HomeAssistant,
start_date: datetime,
end_date: datetime,
) -> list[CalendarEvent]:
"""Get all events in a specific time frame.""" """Get all events in a specific time frame."""
return await self.data.async_get_events(hass, start_date, end_date) return await self.data.async_get_events(hass, start_date, end_date)
@ -336,7 +342,7 @@ class TodoistProjectDevice(CalendarEventDevice):
class TodoistProjectData: class TodoistProjectData:
""" """
Class used by the Task Device service object to hold all Todoist Tasks. Class used by the Task Entity service object to hold all Todoist Tasks.
This is analogous to the GoogleCalendarData found in the Google Calendar This is analogous to the GoogleCalendarData found in the Google Calendar
component. component.
@ -409,6 +415,22 @@ class TodoistProjectData:
else: else:
self._project_id_whitelist = [] self._project_id_whitelist = []
@property
def calendar_event(self) -> CalendarEvent | None:
"""Return the next upcoming calendar event."""
if not self.event:
return None
if not self.event.get(END) or self.event.get(ALL_DAY):
start = self.event[START].date()
return CalendarEvent(
summary=self.event[SUMMARY],
start=start,
end=start + timedelta(days=1),
)
return CalendarEvent(
summary=self.event[SUMMARY], start=self.event[START], end=self.event[END]
)
def create_todoist_task(self, data): def create_todoist_task(self, data):
""" """
Create a dictionary based on a Task passed from the Todoist API. Create a dictionary based on a Task passed from the Todoist API.
@ -566,7 +588,7 @@ class TodoistProjectData:
if task["due"] is None: if task["due"] is None:
continue continue
# @NOTE: _parse_due_date always returns the date in UTC time. # @NOTE: _parse_due_date always returns the date in UTC time.
due_date = _parse_due_date( due_date: datetime | None = _parse_due_date(
task["due"], self._api.state["user"]["tz_info"]["hours"] task["due"], self._api.state["user"]["tz_info"]["hours"]
) )
if not due_date: if not due_date:
@ -580,20 +602,16 @@ class TodoistProjectData:
) )
if start_date < due_date < end_date: if start_date < due_date < end_date:
due_date_value: datetime | date = due_date
if due_date == midnight: if due_date == midnight:
# If the due date has no time data, return just the date so that it # If the due date has no time data, return just the date so that it
# will render correctly as an all day event on a calendar. # will render correctly as an all day event on a calendar.
due_date_value = due_date.strftime("%Y-%m-%d") due_date_value = due_date.date()
else: event = CalendarEvent(
due_date_value = due_date.isoformat() summary=task["content"],
event = { start=due_date_value,
"uid": task["id"], end=due_date_value,
"title": task["content"], )
"start": due_date_value,
"end": due_date_value,
"allDay": True,
"summary": task["content"],
}
events.append(event) events.append(event)
return events return events
@ -644,22 +662,10 @@ class TodoistProjectData:
project_tasks.remove(best_task) project_tasks.remove(best_task)
self.all_project_tasks.append(best_task) self.all_project_tasks.append(best_task)
self.event = self.all_project_tasks[0] event = self.all_project_tasks[0]
if event is None or event[START] is None:
# Convert datetime to a string again _LOGGER.debug("No valid event or event start for %s", self._name)
if self.event is not None: self.event = None
if self.event[START] is not None: return
self.event[START] = { self.event = event
DATETIME: self.event[START].strftime(DATE_STR_FORMAT)
}
if self.event[END] is not None:
self.event[END] = {DATETIME: self.event[END].strftime(DATE_STR_FORMAT)}
else:
# Home Assistant gets cranky if a calendar event never ends
# Let's set our "due date" to tomorrow
self.event[END] = {
DATETIME: (datetime.utcnow() + timedelta(days=1)).strftime(
DATE_STR_FORMAT
)
}
_LOGGER.debug("Updated %s", self._name) _LOGGER.debug("Updated %s", self._name)

View File

@ -2,9 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
from homeassistant.components.calendar import CalendarEventDevice from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -26,7 +25,7 @@ async def async_setup_entry(
async_add_entities([TwenteMilieuCalendar(coordinator, entry)]) async_add_entities([TwenteMilieuCalendar(coordinator, entry)])
class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice): class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity):
"""Defines a Twente Milieu calendar.""" """Defines a Twente Milieu calendar."""
_attr_name = "Twente Milieu" _attr_name = "Twente Milieu"
@ -40,26 +39,25 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice):
"""Initialize the Twente Milieu entity.""" """Initialize the Twente Milieu entity."""
super().__init__(coordinator, entry) super().__init__(coordinator, entry)
self._attr_unique_id = str(entry.data[CONF_ID]) self._attr_unique_id = str(entry.data[CONF_ID])
self._event: dict[str, Any] | None = None self._event: CalendarEvent | None = None
@property @property
def event(self) -> dict[str, Any] | None: def event(self) -> CalendarEvent | None:
"""Return the next upcoming event.""" """Return the next upcoming event."""
return self._event return self._event
async def async_get_events( async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[dict[str, Any]]: ) -> list[CalendarEvent]:
"""Return calendar events within a datetime range.""" """Return calendar events within a datetime range."""
events: list[dict[str, Any]] = [] events: list[CalendarEvent] = []
for waste_type, waste_dates in self.coordinator.data.items(): for waste_type, waste_dates in self.coordinator.data.items():
events.extend( events.extend(
{ CalendarEvent(
"all_day": True, summary=WASTE_TYPE_TO_DESCRIPTION[waste_type],
"start": {"date": waste_date.isoformat()}, start=waste_date,
"end": {"date": waste_date.isoformat()}, end=waste_date,
"summary": WASTE_TYPE_TO_DESCRIPTION[waste_type], )
}
for waste_date in waste_dates for waste_date in waste_dates
if start_date.date() <= waste_date <= end_date.date() if start_date.date() <= waste_date <= end_date.date()
) )
@ -86,12 +84,11 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice):
self._event = None self._event = None
if next_waste_pickup_date is not None and next_waste_pickup_type is not None: if next_waste_pickup_date is not None and next_waste_pickup_type is not None:
self._event = { self._event = CalendarEvent(
"all_day": True, summary=WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type],
"start": {"date": next_waste_pickup_date.isoformat()}, start=next_waste_pickup_date,
"end": {"date": next_waste_pickup_date.isoformat()}, end=next_waste_pickup_date,
"summary": WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type], )
}
super()._handle_coordinator_update() super()._handle_coordinator_update()

View File

@ -934,11 +934,8 @@ async def test_get_events_custom_calendars(hass, calendar, get_api_events):
events = await get_api_events("calendar.private_private") events = await get_api_events("calendar.private_private")
assert events == [ assert events == [
{ {
"description": "Surprisingly rainy", "end": {"dateTime": "2017-11-27T10:00:00-08:00"},
"end": "2017-11-27T10:00:00-08:00", "start": {"dateTime": "2017-11-27T09:00:00-08:00"},
"location": "Hamburg",
"start": "2017-11-27T09:00:00-08:00",
"summary": "This is a normal event", "summary": "This is a normal event",
"uid": "1",
} }
] ]

View File

@ -23,7 +23,6 @@ async def test_events_http_api(hass, hass_client):
assert response.status == HTTPStatus.OK assert response.status == HTTPStatus.OK
events = await response.json() events = await response.json()
assert events[0]["summary"] == "Future Event" assert events[0]["summary"] == "Future Event"
assert events[0]["title"] == "Future Event"
async def test_calendars_http_api(hass, hass_client): async def test_calendars_http_api(hass, hass_client):
@ -37,4 +36,24 @@ async def test_calendars_http_api(hass, hass_client):
assert data == [ assert data == [
{"entity_id": "calendar.calendar_1", "name": "Calendar 1"}, {"entity_id": "calendar.calendar_1", "name": "Calendar 1"},
{"entity_id": "calendar.calendar_2", "name": "Calendar 2"}, {"entity_id": "calendar.calendar_2", "name": "Calendar 2"},
{"entity_id": "calendar.calendar_3", "name": "Calendar 3"},
] ]
async def test_events_http_api_shim(hass, hass_client):
"""Test the legacy shim calendar demo view."""
await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}})
await hass.async_block_till_done()
client = await hass_client()
response = await client.get("/api/calendars/calendar.calendar_3")
assert response.status == HTTPStatus.BAD_REQUEST
start = dt_util.now()
end = start + timedelta(days=1)
response = await client.get(
"/api/calendars/calendar.calendar_1?start={}&end={}".format(
start.isoformat(), end.isoformat()
)
)
assert response.status == HTTPStatus.OK
events = await response.json()
assert events[0]["summary"] == "Future Event"

View File

@ -76,7 +76,6 @@ async def test_api_events(
events = await response.json() events = await response.json()
assert len(events) == 1 assert len(events) == 1
assert events[0] == { assert events[0] == {
"all_day": True,
"start": {"date": "2022-01-06"}, "start": {"date": "2022-01-06"},
"end": {"date": "2022-01-06"}, "end": {"date": "2022-01-06"},
"summary": "Christmas Tree Pickup", "summary": "Christmas Tree Pickup",