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

Update calendar handle state updates at start/end of active/upcoming event #98037

Merged
merged 6 commits into from
Aug 26, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
47 changes: 47 additions & 0 deletions homeassistant/components/calendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
Expand All @@ -34,6 +36,7 @@
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
Expand Down Expand Up @@ -478,6 +481,8 @@ def is_offset_reached(
class CalendarEntity(Entity):
"""Base class for calendar event entities."""

_alarm_unsubs: list[CALLBACK_TYPE] = []

@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
Expand Down Expand Up @@ -513,6 +518,48 @@ def state(self) -> str:

return STATE_OFF

@callback
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.

This sets up listeners to handle state transitions for start or end of
the current or upcoming event.
"""
super().async_write_ha_state()

for unsub in self._alarm_unsubs:
unsub()

now = dt_util.now()
event = self.event
if event is None or now >= event.end_datetime_local:
return
if now < event.start_datetime_local:
self._alarm_unsubs.append(
async_track_point_in_time(
self.hass,
self._event_start_or_end_trigger,
event.start_datetime_local,
)
)
self._alarm_unsubs.append(
async_track_point_in_time(
self.hass, self._event_start_or_end_trigger, event.end_datetime_local
)
)

async def _event_start_or_end_trigger(self, _: datetime.datetime) -> None:
allenporter marked this conversation as resolved.
Show resolved Hide resolved
"""Run when the active or upcoming event starts or ends."""
self._async_write_ha_state()

async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.

To be extended by integrations.
"""
for unsub in self._alarm_unsubs:
unsub()

async def async_get_events(
self,
hass: HomeAssistant,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CAMERA,
Platform.CALENDAR,
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
Expand Down Expand Up @@ -54,7 +55,6 @@
Platform.MAILBOX,
Platform.NOTIFY,
Platform.IMAGE_PROCESSING,
Platform.CALENDAR,
Platform.DEVICE_TRACKER,
Platform.WEATHER,
]
Expand Down
15 changes: 7 additions & 8 deletions homeassistant/components/demo/calendar.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
"""Demo platform that has two fake binary sensors."""
"""Demo platform that has two fake calendars."""
from __future__ import annotations

import datetime

from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util


def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Demo Calendar platform."""
add_entities(
"""Set up the Demo Calendar config entry."""
async_add_entities(
[
DemoCalendar(calendar_data_future(), "Calendar 1"),
DemoCalendar(calendar_data_current(), "Calendar 2"),
Expand Down
66 changes: 20 additions & 46 deletions homeassistant/components/google/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity import generate_entity_id
Expand Down Expand Up @@ -383,7 +383,6 @@ def __init__(
self._event: CalendarEvent | None = None
self._attr_name = data[CONF_NAME].capitalize()
self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
self._offset_value: timedelta | None = None
self.entity_id = entity_id
self._attr_unique_id = unique_id
self._attr_entity_registry_enabled_default = entity_enabled
Expand All @@ -392,17 +391,6 @@ def __init__(
CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT
)

@property
def should_poll(self) -> bool:
"""Enable polling for the entity.

The coordinator is not used by multiple entities, but instead
is used to poll the calendar API at a separate interval from the
entity state updates itself which happen more frequently (e.g. to
fire an alarm when the next event starts).
"""
return True

@property
def extra_state_attributes(self) -> dict[str, bool]:
"""Return the device state attributes."""
Expand All @@ -411,16 +399,16 @@ def extra_state_attributes(self) -> dict[str, bool]:
@property
def offset_reached(self) -> bool:
"""Return whether or not the event offset was reached."""
if self._event and self._offset_value:
return is_offset_reached(
self._event.start_datetime_local, self._offset_value
)
(event, offset_value) = self._event_with_offset()
if event is not None and offset_value is not None:
return is_offset_reached(event.start_datetime_local, offset_value)
return False

@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
return self._event
(event, _) = self._event_with_offset()
return event

def _event_filter(self, event: Event) -> bool:
"""Return True if the event is visible."""
Expand All @@ -435,12 +423,10 @@ async def async_added_to_hass(self) -> None:
# We do not ask for an update with async_add_entities()
# because it will update disabled entities. This is started as a
# task to let if sync in the background without blocking startup
async def refresh() -> None:
await self.coordinator.async_request_refresh()
self._apply_coordinator_update()

self.coordinator.config_entry.async_create_background_task(
self.hass, refresh(), "google.calendar-refresh"
self.hass,
self.coordinator.async_request_refresh(),
"google.calendar-refresh",
)

async def async_get_events(
Expand All @@ -453,36 +439,24 @@ async def async_get_events(
for event in filter(self._event_filter, result_items)
]

def _apply_coordinator_update(self) -> None:
"""Copy state from the coordinator to this entity."""
def _event_with_offset(
self,
) -> tuple[CalendarEvent | None, timedelta | None]:
"""Get the calendar event and offset if any."""
if api_event := next(
filter(
self._event_filter,
self.coordinator.upcoming or [],
),
None,
):
self._event = _get_calendar_event(api_event)
(self._event.summary, self._offset_value) = extract_offset(
self._event.summary, self._offset
)
else:
self._event = None

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._apply_coordinator_update()
super()._handle_coordinator_update()

async def async_update(self) -> None:
"""Disable update behavior.

This relies on the coordinator callback update to write home assistant
state with the next calendar event. This update is a no-op as no new data
fetch is needed to evaluate the state to determine if the next event has
started, handled by CalendarEntity parent class.
"""
event = _get_calendar_event(api_event)
if self._offset:
(event.summary, offset_value) = extract_offset(
event.summary, self._offset
)
return event, offset_value
return None, None

async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar."""
Expand Down