Add update coordinator for google calendar (#74690)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-07-10 21:24:52 -07:00 committed by GitHub
parent 5451ccd2b5
commit f4e61eff18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 160 additions and 48 deletions

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import copy
from datetime import datetime, timedelta
import logging
from typing import Any
@ -21,7 +20,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
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import (
config_validation as cv,
@ -30,7 +29,11 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from . import (
CONF_IGNORE_AVAILABILITY,
@ -182,9 +185,17 @@ async def async_setup_entry(
entity_registry.async_remove(
entity_entry.entity_id,
)
coordinator = CalendarUpdateCoordinator(
hass,
calendar_service,
data[CONF_NAME],
calendar_id,
data.get(CONF_SEARCH),
)
await coordinator.async_config_entry_first_refresh()
entities.append(
GoogleCalendarEntity(
calendar_service,
coordinator,
calendar_id,
data,
generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass),
@ -213,14 +224,66 @@ async def async_setup_entry(
)
class GoogleCalendarEntity(CalendarEntity):
"""A calendar event device."""
class CalendarUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator for calendar RPC calls."""
def __init__(
self,
hass: HomeAssistant,
calendar_service: GoogleCalendarService,
name: str,
calendar_id: str,
search: str | None,
) -> None:
"""Create the Calendar event device."""
super().__init__(
hass,
_LOGGER,
name=name,
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
self.calendar_service = calendar_service
self.calendar_id = calendar_id
self._search = search
async def async_get_events(
self, start_date: datetime, end_date: datetime
) -> list[Event]:
"""Get all events in a specific time frame."""
request = ListEventsRequest(
calendar_id=self.calendar_id,
start_time=start_date,
end_time=end_date,
search=self._search,
)
result_items = []
try:
result = await self.calendar_service.async_list_events(request)
async for result_page in result:
result_items.extend(result_page.items)
except ApiException as err:
self.async_set_update_error(err)
raise HomeAssistantError(str(err)) from err
return result_items
async def _async_update_data(self) -> list[Event]:
"""Fetch data from API endpoint."""
request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search)
try:
result = await self.calendar_service.async_list_events(request)
except ApiException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
return result.items
class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
"""A calendar event entity."""
_attr_has_entity_name = True
def __init__(
self,
calendar_service: GoogleCalendarService,
coordinator: CalendarUpdateCoordinator,
calendar_id: str,
data: dict[str, Any],
entity_id: str,
@ -228,9 +291,9 @@ class GoogleCalendarEntity(CalendarEntity):
entity_enabled: bool,
) -> None:
"""Create the Calendar event device."""
self.calendar_service = calendar_service
super().__init__(coordinator)
self.coordinator = coordinator
self.calendar_id = calendar_id
self._search: str | None = data.get(CONF_SEARCH)
self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False)
self._event: CalendarEvent | None = None
self._attr_name = data[CONF_NAME].capitalize()
@ -240,6 +303,17 @@ class GoogleCalendarEntity(CalendarEntity):
self._attr_unique_id = unique_id
self._attr_entity_registry_enabled_default = entity_enabled
@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."""
@ -265,48 +339,44 @@ class GoogleCalendarEntity(CalendarEntity):
return True
return event.transparency == OPAQUE
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._apply_coordinator_update()
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
request = ListEventsRequest(
calendar_id=self.calendar_id,
start_time=start_date,
end_time=end_date,
search=self._search,
)
result_items = []
try:
result = await self.calendar_service.async_list_events(request)
async for result_page in result:
result_items.extend(result_page.items)
except ApiException as err:
raise HomeAssistantError(str(err)) from err
result_items = await self.coordinator.async_get_events(start_date, end_date)
return [
_get_calendar_event(event)
for event in filter(self._event_filter, result_items)
]
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None:
"""Get the latest data."""
request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search)
try:
result = await self.calendar_service.async_list_events(request)
except ApiException as err:
_LOGGER.error("Unable to connect to Google: %s", err)
return
def _apply_coordinator_update(self) -> None:
"""Copy state from the coordinator to this entity."""
events = self.coordinator.data
self._event = _get_calendar_event(next(iter(events))) if events else None
if self._event:
(self._event.summary, self._offset_value) = extract_offset(
self._event.summary, self._offset
)
# Pick the first visible event and apply offset calculations.
valid_items = filter(self._event_filter, result.items)
event = copy.deepcopy(next(valid_items, None))
if event:
(event.summary, offset) = extract_offset(event.summary, self._offset)
self._event = _get_calendar_event(event)
self._offset_value = 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.
"""
def _get_calendar_event(event: Event) -> CalendarEvent:
@ -359,7 +429,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) ->
raise ValueError("Missing required fields to set start or end date/datetime")
try:
await entity.calendar_service.async_create_event(
await entity.coordinator.calendar_service.async_create_event(
entity.calendar_id,
Event(
summary=call.data[EVENT_SUMMARY],

View File

@ -282,6 +282,15 @@ class DataUpdateCoordinator(Generic[_T]):
self.async_update_listeners()
@callback
def async_set_update_error(self, err: Exception) -> None:
"""Manually set an error, log the message and notify listeners."""
self.last_exception = err
if self.last_update_success:
self.logger.error("Error requesting %s data: %s", self.name, err)
self.last_update_success = False
self.async_update_listeners()
@callback
def async_set_updated_data(self, data: _T) -> None:
"""Manually update data, notify listeners and reset refresh interval."""

View File

@ -341,7 +341,7 @@ async def test_update_error(
assert state.name == TEST_ENTITY_NAME
assert state.state == "on"
# Advance time to avoid throttling
# Advance time to next data update interval
now += datetime.timedelta(minutes=30)
aioclient_mock.clear_requests()
@ -351,12 +351,12 @@ async def test_update_error(
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
# No change
# Entity is marked uanvailable due to API failure
state = hass.states.get(TEST_ENTITY)
assert state.name == TEST_ENTITY_NAME
assert state.state == "on"
assert state.state == "unavailable"
# Advance time beyond update/throttle point
# Advance time past next coordinator update
now += datetime.timedelta(minutes=30)
aioclient_mock.clear_requests()
@ -380,7 +380,7 @@ async def test_update_error(
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
# State updated
# State updated with new API response
state = hass.states.get(TEST_ENTITY)
assert state.name == TEST_ENTITY_NAME
assert state.state == "off"
@ -425,6 +425,10 @@ async def test_http_event_api_failure(
response = await client.get(upcoming_event_url())
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
state = hass.states.get(TEST_ENTITY)
assert state.name == TEST_ENTITY_NAME
assert state.state == "unavailable"
@pytest.mark.freeze_time("2022-03-27 12:05:00+00:00")
async def test_http_api_event(
@ -613,7 +617,7 @@ async def test_future_event_update_behavior(
# Advance time until event has started
now += datetime.timedelta(minutes=60)
now_utc += datetime.timedelta(minutes=30)
now_utc += datetime.timedelta(minutes=60)
with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch(
"homeassistant.util.dt.now", return_value=now
):

View File

@ -402,3 +402,32 @@ async def test_not_schedule_refresh_if_system_option_disable_polling(hass):
crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL)
crd.async_add_listener(lambda: None)
assert crd._unsub_refresh is None
async def test_async_set_update_error(crd, caplog):
"""Test manually setting an update failure."""
update_callback = Mock()
crd.async_add_listener(update_callback)
crd.async_set_update_error(aiohttp.ClientError("Client Failure #1"))
assert crd.last_update_success is False
assert "Client Failure #1" in caplog.text
update_callback.assert_called_once()
update_callback.reset_mock()
# Additional failure does not log or change state
crd.async_set_update_error(aiohttp.ClientError("Client Failure #2"))
assert crd.last_update_success is False
assert "Client Failure #2" not in caplog.text
update_callback.assert_not_called()
update_callback.reset_mock()
crd.async_set_updated_data(200)
assert crd.last_update_success is True
update_callback.assert_called_once()
update_callback.reset_mock()
crd.async_set_update_error(aiohttp.ClientError("Client Failure #3"))
assert crd.last_update_success is False
assert "Client Failure #2" not in caplog.text
update_callback.assert_called_once()