From 8d9c5a61ec09a458bdccc8dc37a4648ad918f1d8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 25 Aug 2023 18:32:20 -0700 Subject: [PATCH] Update calendar handle state updates at start/end of active/upcoming event (#98037) * Update calendar handle state updates at start/end of active/upcoming event * Use async_write_ha_state intercept state updates Remove unrelated changes and whitespace. * Revert unnecessary changes * Move demo calendar to config entries to cleanup event timers * Fix docs on calendars * Move method inside from PR feedback --- homeassistant/components/calendar/__init__.py | 47 +++++++++++++ homeassistant/components/demo/__init__.py | 2 +- homeassistant/components/demo/calendar.py | 15 ++--- homeassistant/components/google/calendar.py | 66 ++++++------------- 4 files changed, 75 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c85f0d2bff1..e487569453f 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -20,10 +20,12 @@ from homeassistant.components.websocket_api.connection import ActiveConnection 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 @@ -34,6 +36,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) 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 @@ -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.""" @@ -513,6 +518,48 @@ class CalendarEntity(Entity): 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 + + @callback + def update(_: datetime.datetime) -> None: + """Run when the active or upcoming event starts or ends.""" + self._async_write_ha_state() + + if now < event.start_datetime_local: + self._alarm_unsubs.append( + async_track_point_in_time( + self.hass, + update, + event.start_datetime_local, + ) + ) + self._alarm_unsubs.append( + async_track_point_in_time(self.hass, update, event.end_datetime_local) + ) + + 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, diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 04eba5f0586..b40e1ede232 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -26,6 +26,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.CALENDAR, Platform.CLIMATE, Platform.COVER, Platform.DATE, @@ -54,7 +55,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.MAILBOX, Platform.NOTIFY, Platform.IMAGE_PROCESSING, - Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index 73b45a55640..b4200f1be89 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -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"), diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 347e8444946..9559a06d49c 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -36,7 +36,7 @@ from homeassistant.components.calendar import ( ) 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 @@ -383,7 +383,6 @@ class GoogleCalendarEntity( 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 @@ -392,17 +391,6 @@ class GoogleCalendarEntity( 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.""" @@ -411,16 +399,16 @@ class GoogleCalendarEntity( @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.""" @@ -435,12 +423,10 @@ class GoogleCalendarEntity( # 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( @@ -453,8 +439,10 @@ class GoogleCalendarEntity( 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, @@ -462,27 +450,13 @@ class GoogleCalendarEntity( ), 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."""