mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 21:27:38 +00:00
Add update coordinator for google calendar (#74690)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
5451ccd2b5
commit
f4e61eff18
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -21,7 +20,7 @@ from homeassistant.components.calendar import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
|
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.exceptions import HomeAssistantError, PlatformNotReady
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import (
|
||||||
config_validation as cv,
|
config_validation as cv,
|
||||||
@ -30,7 +29,11 @@ from homeassistant.helpers import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.entity import generate_entity_id
|
from homeassistant.helpers.entity import generate_entity_id
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
CONF_IGNORE_AVAILABILITY,
|
CONF_IGNORE_AVAILABILITY,
|
||||||
@ -182,9 +185,17 @@ async def async_setup_entry(
|
|||||||
entity_registry.async_remove(
|
entity_registry.async_remove(
|
||||||
entity_entry.entity_id,
|
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(
|
entities.append(
|
||||||
GoogleCalendarEntity(
|
GoogleCalendarEntity(
|
||||||
calendar_service,
|
coordinator,
|
||||||
calendar_id,
|
calendar_id,
|
||||||
data,
|
data,
|
||||||
generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass),
|
generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass),
|
||||||
@ -213,14 +224,66 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GoogleCalendarEntity(CalendarEntity):
|
class CalendarUpdateCoordinator(DataUpdateCoordinator):
|
||||||
"""A calendar event device."""
|
"""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
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
calendar_service: GoogleCalendarService,
|
coordinator: CalendarUpdateCoordinator,
|
||||||
calendar_id: str,
|
calendar_id: str,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
@ -228,9 +291,9 @@ class GoogleCalendarEntity(CalendarEntity):
|
|||||||
entity_enabled: bool,
|
entity_enabled: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create the Calendar event device."""
|
"""Create the Calendar event device."""
|
||||||
self.calendar_service = calendar_service
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
self.calendar_id = calendar_id
|
self.calendar_id = calendar_id
|
||||||
self._search: str | None = data.get(CONF_SEARCH)
|
|
||||||
self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False)
|
self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False)
|
||||||
self._event: CalendarEvent | None = None
|
self._event: CalendarEvent | None = None
|
||||||
self._attr_name = data[CONF_NAME].capitalize()
|
self._attr_name = data[CONF_NAME].capitalize()
|
||||||
@ -240,6 +303,17 @@ class GoogleCalendarEntity(CalendarEntity):
|
|||||||
self._attr_unique_id = unique_id
|
self._attr_unique_id = unique_id
|
||||||
self._attr_entity_registry_enabled_default = entity_enabled
|
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
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, bool]:
|
def extra_state_attributes(self) -> dict[str, bool]:
|
||||||
"""Return the device state attributes."""
|
"""Return the device state attributes."""
|
||||||
@ -265,48 +339,44 @@ class GoogleCalendarEntity(CalendarEntity):
|
|||||||
return True
|
return True
|
||||||
return event.transparency == OPAQUE
|
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(
|
async def async_get_events(
|
||||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||||
) -> list[CalendarEvent]:
|
) -> list[CalendarEvent]:
|
||||||
"""Get all events in a specific time frame."""
|
"""Get all events in a specific time frame."""
|
||||||
|
result_items = await self.coordinator.async_get_events(start_date, end_date)
|
||||||
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
|
|
||||||
return [
|
return [
|
||||||
_get_calendar_event(event)
|
_get_calendar_event(event)
|
||||||
for event in filter(self._event_filter, result_items)
|
for event in filter(self._event_filter, result_items)
|
||||||
]
|
]
|
||||||
|
|
||||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
def _apply_coordinator_update(self) -> None:
|
||||||
async def async_update(self) -> None:
|
"""Copy state from the coordinator to this entity."""
|
||||||
"""Get the latest data."""
|
events = self.coordinator.data
|
||||||
request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search)
|
self._event = _get_calendar_event(next(iter(events))) if events else None
|
||||||
try:
|
if self._event:
|
||||||
result = await self.calendar_service.async_list_events(request)
|
(self._event.summary, self._offset_value) = extract_offset(
|
||||||
except ApiException as err:
|
self._event.summary, self._offset
|
||||||
_LOGGER.error("Unable to connect to Google: %s", err)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
# Pick the first visible event and apply offset calculations.
|
@callback
|
||||||
valid_items = filter(self._event_filter, result.items)
|
def _handle_coordinator_update(self) -> None:
|
||||||
event = copy.deepcopy(next(valid_items, None))
|
"""Handle updated data from the coordinator."""
|
||||||
if event:
|
self._apply_coordinator_update()
|
||||||
(event.summary, offset) = extract_offset(event.summary, self._offset)
|
super()._handle_coordinator_update()
|
||||||
self._event = _get_calendar_event(event)
|
|
||||||
self._offset_value = offset
|
async def async_update(self) -> None:
|
||||||
else:
|
"""Disable update behavior.
|
||||||
self._event = None
|
|
||||||
|
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:
|
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")
|
raise ValueError("Missing required fields to set start or end date/datetime")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await entity.calendar_service.async_create_event(
|
await entity.coordinator.calendar_service.async_create_event(
|
||||||
entity.calendar_id,
|
entity.calendar_id,
|
||||||
Event(
|
Event(
|
||||||
summary=call.data[EVENT_SUMMARY],
|
summary=call.data[EVENT_SUMMARY],
|
||||||
|
@ -282,6 +282,15 @@ class DataUpdateCoordinator(Generic[_T]):
|
|||||||
|
|
||||||
self.async_update_listeners()
|
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
|
@callback
|
||||||
def async_set_updated_data(self, data: _T) -> None:
|
def async_set_updated_data(self, data: _T) -> None:
|
||||||
"""Manually update data, notify listeners and reset refresh interval."""
|
"""Manually update data, notify listeners and reset refresh interval."""
|
||||||
|
@ -341,7 +341,7 @@ async def test_update_error(
|
|||||||
assert state.name == TEST_ENTITY_NAME
|
assert state.name == TEST_ENTITY_NAME
|
||||||
assert state.state == "on"
|
assert state.state == "on"
|
||||||
|
|
||||||
# Advance time to avoid throttling
|
# Advance time to next data update interval
|
||||||
now += datetime.timedelta(minutes=30)
|
now += datetime.timedelta(minutes=30)
|
||||||
|
|
||||||
aioclient_mock.clear_requests()
|
aioclient_mock.clear_requests()
|
||||||
@ -351,12 +351,12 @@ async def test_update_error(
|
|||||||
async_fire_time_changed(hass, now)
|
async_fire_time_changed(hass, now)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
# No change
|
# Entity is marked uanvailable due to API failure
|
||||||
state = hass.states.get(TEST_ENTITY)
|
state = hass.states.get(TEST_ENTITY)
|
||||||
assert state.name == TEST_ENTITY_NAME
|
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)
|
now += datetime.timedelta(minutes=30)
|
||||||
|
|
||||||
aioclient_mock.clear_requests()
|
aioclient_mock.clear_requests()
|
||||||
@ -380,7 +380,7 @@ async def test_update_error(
|
|||||||
async_fire_time_changed(hass, now)
|
async_fire_time_changed(hass, now)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
# State updated
|
# State updated with new API response
|
||||||
state = hass.states.get(TEST_ENTITY)
|
state = hass.states.get(TEST_ENTITY)
|
||||||
assert state.name == TEST_ENTITY_NAME
|
assert state.name == TEST_ENTITY_NAME
|
||||||
assert state.state == "off"
|
assert state.state == "off"
|
||||||
@ -425,6 +425,10 @@ async def test_http_event_api_failure(
|
|||||||
response = await client.get(upcoming_event_url())
|
response = await client.get(upcoming_event_url())
|
||||||
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
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")
|
@pytest.mark.freeze_time("2022-03-27 12:05:00+00:00")
|
||||||
async def test_http_api_event(
|
async def test_http_api_event(
|
||||||
@ -613,7 +617,7 @@ async def test_future_event_update_behavior(
|
|||||||
|
|
||||||
# Advance time until event has started
|
# Advance time until event has started
|
||||||
now += datetime.timedelta(minutes=60)
|
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(
|
with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch(
|
||||||
"homeassistant.util.dt.now", return_value=now
|
"homeassistant.util.dt.now", return_value=now
|
||||||
):
|
):
|
||||||
|
@ -402,3 +402,32 @@ async def test_not_schedule_refresh_if_system_option_disable_polling(hass):
|
|||||||
crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL)
|
crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL)
|
||||||
crd.async_add_listener(lambda: None)
|
crd.async_add_listener(lambda: None)
|
||||||
assert crd._unsub_refresh is 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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user