diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index ed3a27ce61447..7fb55f3cfb7d9 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -3,14 +3,14 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +import dataclasses from datetime import datetime, timedelta import logging from typing import Any, cast from gcal_sync.api import Range, SyncEventsRequest from gcal_sync.exceptions import ApiException -from gcal_sync.model import AccessRole, Calendar, DateOrDatetime, Event +from gcal_sync.model import AccessRole, Calendar, DateOrDatetime, Event, EventTypeEnum from gcal_sync.store import ScopedCalendarStore from gcal_sync.sync import CalendarEventSyncManager @@ -84,18 +84,19 @@ SERVICE_CREATE_EVENT = "create_event" -@dataclass(frozen=True, kw_only=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class GoogleCalendarEntityDescription(CalendarEntityDescription): """Google calendar entity description.""" - name: str - entity_id: str + name: str | None + entity_id: str | None read_only: bool ignore_availability: bool offset: str | None search: str | None local_sync: bool device_id: str + working_location: bool = False def _get_entity_descriptions( @@ -142,22 +143,42 @@ def _get_entity_descriptions( ) or calendar_item.access_role == AccessRole.FREE_BUSY_READER: read_only = True local_sync = False - entity_descriptions.append( - GoogleCalendarEntityDescription( - key=key, - name=data[CONF_NAME].capitalize(), - entity_id=generate_entity_id( - ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass - ), - read_only=read_only, - ignore_availability=data.get(CONF_IGNORE_AVAILABILITY, False), - offset=data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET), - search=search, - local_sync=local_sync, - entity_registry_enabled_default=entity_enabled, - device_id=data[CONF_DEVICE_ID], - ) + entity_description = GoogleCalendarEntityDescription( + key=key, + name=data[CONF_NAME].capitalize(), + entity_id=generate_entity_id( + ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass + ), + read_only=read_only, + ignore_availability=data.get(CONF_IGNORE_AVAILABILITY, False), + offset=data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET), + search=search, + local_sync=local_sync, + entity_registry_enabled_default=entity_enabled, + device_id=data[CONF_DEVICE_ID], + ) + entity_descriptions.append(entity_description) + _LOGGER.debug( + "calendar_item.primary=%s, search=%s, calendar_item.access_role=%s - %s", + calendar_item.primary, + search, + calendar_item.access_role, + local_sync, ) + if calendar_item.primary and local_sync: + _LOGGER.debug("work location entity") + # Create an optional disabled by default entity for Work Location + entity_descriptions.append( + dataclasses.replace( + entity_description, + key=f"{key}-work-location", + translation_key="working_location", + working_location=True, + name=None, + entity_id=None, + entity_registry_enabled_default=False, + ) + ) return entity_descriptions @@ -233,12 +254,13 @@ async def async_setup_entry( entity_registry.async_remove( entity_entry.entity_id, ) + _LOGGER.debug("Creating entity with unique_id=%s", unique_id) coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator if not entity_description.local_sync: coordinator = CalendarQueryUpdateCoordinator( hass, calendar_service, - entity_description.name, + entity_description.name or entity_description.key, calendar_id, entity_description.search, ) @@ -257,7 +279,7 @@ async def async_setup_entry( coordinator = CalendarSyncUpdateCoordinator( hass, sync, - entity_description.name, + entity_description.name or entity_description.key, ) entities.append( GoogleCalendarEntity( @@ -310,12 +332,15 @@ def __init__( ) -> None: """Create the Calendar event device.""" super().__init__(coordinator) + _LOGGER.debug("entity_description.entity_id=%s", entity_description.entity_id) + _LOGGER.debug("entity_description=%s", entity_description) self.calendar_id = calendar_id self.entity_description = entity_description self._ignore_availability = entity_description.ignore_availability self._offset = entity_description.offset self._event: CalendarEvent | None = None - self.entity_id = entity_description.entity_id + if entity_description.entity_id: + self.entity_id = entity_description.entity_id self._attr_unique_id = unique_id if not entity_description.read_only: self._attr_supported_features = ( @@ -343,6 +368,8 @@ def event(self) -> CalendarEvent | None: def _event_filter(self, event: Event) -> bool: """Return True if the event is visible.""" + if event.event_type == EventTypeEnum.WORKING_LOCATION: + return self.entity_description.working_location if self._ignore_availability: return True return event.transparency == OPAQUE diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index fd817f8224615..05c7b8ab190fa 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -123,5 +123,12 @@ } } } + }, + "entity": { + "calendar": { + "working_location": { + "name": "Working location" + } + } } } diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 791e5613b0b8d..23b6b8841457f 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -98,12 +98,21 @@ def calendar_access_role() -> str: return "owner" +@pytest.fixture +def calendar_is_primary() -> bool: + """Set if the calendar is the primary or not.""" + return False + + @pytest.fixture(name="test_api_calendar") -def api_calendar(calendar_access_role: str) -> dict[str, Any]: +def api_calendar( + calendar_access_role: str, calendar_is_primary: bool +) -> dict[str, Any]: """Return a test calendar object used in API responses.""" return { **TEST_API_CALENDAR, "accessRole": calendar_access_role, + "primary": calendar_is_primary, } diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 11d4ec46bd18e..03b171c5e19d6 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -15,9 +15,11 @@ import pytest from homeassistant.components.google.const import CONF_CALENDAR_ACCESS, DOMAIN +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.helpers.template import DATE_STR_FORMAT import homeassistant.util.dt as dt_util @@ -1359,3 +1361,90 @@ async def test_invalid_rrule_fix( assert event["uid"] == "cydrevtfuybguinhomj@google.com" assert event["recurrence_id"] == "_c8rinwq863h45qnucyoi43ny8_20230915" assert event["rrule"] is None + + +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("default", "Test All Day Event"), + ("workingLocation", None), + ], +) +async def test_working_location_ignored( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, +) -> None: + """Test working location events are skipped.""" + event = { + **TEST_EVENT, + **upcoming(), + "eventType": event_type, + } + mock_events_list_items([event]) + assert await component_setup() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == TEST_ENTITY_NAME + assert state.attributes.get("message") == expected_event_message + + +@pytest.mark.parametrize("calendar_is_primary", [True]) +async def test_working_location_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, +) -> None: + """Test that working location events are registered under a disabled by default entity.""" + event = { + **TEST_EVENT, + **upcoming(), + "eventType": "workingLocation", + } + mock_events_list_items([event]) + assert await component_setup() + + entity_entry = entity_registry.async_get("calendar.working_location") + assert entity_entry + assert entity_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity( + entity_id="calendar.working_location", disabled_by=None + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + datetime.timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get("calendar.working_location") + assert state + assert state.name == "Working location" + assert state.attributes.get("message") == "Test All Day Event" + + +@pytest.mark.parametrize("calendar_is_primary", [False]) +async def test_no_working_location_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, +) -> None: + """Test that working location events are not registered for a secondary calendar.""" + event = { + **TEST_EVENT, + **upcoming(), + "eventType": "workingLocation", + } + mock_events_list_items([event]) + assert await component_setup() + + entity_entry = entity_registry.async_get("calendar.working_location") + assert not entity_entry