diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 79e4b2da114..ca98b3da087 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -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], diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index fc619469500..30da847642b 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -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.""" diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index e49eb0c2e01..f4129eb0926 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -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 ): diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 0d0970a4756..4e5f07a2232 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -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()