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
This commit is contained in:
Allen Porter 2023-08-25 18:32:20 -07:00 committed by GitHub
parent 544d6b05a5
commit 8d9c5a61ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 75 additions and 55 deletions

View file

@ -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,

View file

@ -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,
]

View file

@ -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"),

View file

@ -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
event = _get_calendar_event(api_event)
if self._offset:
(event.summary, offset_value) = extract_offset(
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.
"""
return event, offset_value
return None, None
async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar."""