diff --git a/.strict-typing b/.strict-typing index 563e6e9f91a..2b6295b9157 100644 --- a/.strict-typing +++ b/.strict-typing @@ -101,6 +101,7 @@ homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* homeassistant.components.goalzero.* +homeassistant.components.google.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 73b7f8d8ae6..70732af1fd8 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -97,23 +97,27 @@ PLATFORMS = ["calendar"] CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_TRACK_NEW, default=True): cv.boolean, - vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum( - FeatureAccess - ), - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_TRACK_NEW, default=True): cv.boolean, + vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum( + FeatureAccess + ), + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) _SINGLE_CALSEARCH_CONFIG = vol.All( cv.deprecated(CONF_MAX_RESULTS), + cv.deprecated(CONF_TRACK), vol.Schema( { vol.Required(CONF_NAME): cv.string, @@ -160,6 +164,9 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Google component.""" + if DOMAIN not in config: + return True + conf = config.get(DOMAIN, {}) hass.data[DOMAIN] = {DATA_CONFIG: conf} @@ -189,11 +196,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) ) + + _LOGGER.warning( + "Configuration of Google Calendar in YAML in configuration.yaml is " + "is deprecated and will be removed in a future release; Your existing " + "OAuth Application Credentials and other settings have been imported " + "into the UI automatically and can be safely removed from your " + "configuration.yaml file" + ) + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + async_upgrade_entry(hass, entry) implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -216,8 +234,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - access = get_feature_access(hass) - if access.scope not in session.token.get("scope", []): + access = FeatureAccess[entry.options[CONF_CALENDAR_ACCESS]] + token_scopes = session.token.get("scope", []) + if access.scope not in token_scopes: + _LOGGER.debug("Scope '%s' not in scopes '%s'", access.scope, token_scopes) raise ConfigEntryAuthFailed( "Required scopes are not available, reauth required" ) @@ -226,25 +246,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][DATA_SERVICE] = calendar_service - track_new = hass.data[DOMAIN][DATA_CONFIG].get(CONF_TRACK_NEW, True) - await async_setup_services(hass, track_new, calendar_service) + await async_setup_services(hass, calendar_service) # Only expose the add event service if we have the correct permissions if access is FeatureAccess.read_write: await async_setup_add_event_service(hass, calendar_service) hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # Reload entry when options are updated + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True +def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Upgrade the config entry if needed.""" + if DATA_CONFIG not in hass.data[DOMAIN] and entry.options: + return + + options = ( + entry.options + if entry.options + else { + CONF_CALENDAR_ACCESS: get_feature_access(hass).name, + } + ) + disable_new_entities = ( + not hass.data[DOMAIN].get(DATA_CONFIG, {}).get(CONF_TRACK_NEW, True) + ) + hass.config_entries.async_update_entry( + entry, + options=options, + pref_disable_new_entities=disable_new_entities, + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when it changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_setup_services( hass: HomeAssistant, - track_new: bool, calendar_service: GoogleCalendarService, ) -> None: """Set up the service listeners.""" @@ -256,10 +304,7 @@ async def async_setup_services( async def _found_calendar(calendar_item: Calendar) -> None: calendar = get_calendar_info( hass, - { - **calendar_item.dict(exclude_unset=True), - CONF_TRACK: track_new, - }, + calendar_item.dict(exclude_unset=True), ) calendar_id = calendar_item.id # Populate the yaml file with all discovered calendars @@ -363,7 +408,6 @@ def get_calendar_info( CONF_CAL_ID: calendar["id"], CONF_ENTITIES: [ { - CONF_TRACK: calendar["track"], CONF_NAME: calendar["summary"], CONF_DEVICE_ID: generate_entity_id( "{}", calendar["summary"], hass=hass diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 3ce21fbb03d..eeac854a2ae 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable import datetime import logging import time -from typing import Any +from typing import Any, cast import aiohttp from gcal_sync.auth import AbstractAuth @@ -76,12 +76,12 @@ class DeviceFlow: @property def verification_url(self) -> str: """Return the verification url that the user should visit to enter the code.""" - return self._device_flow_info.verification_url + return self._device_flow_info.verification_url # type: ignore[no-any-return] @property def user_code(self) -> str: """Return the code that the user should enter at the verification url.""" - return self._device_flow_info.user_code + return self._device_flow_info.user_code # type: ignore[no-any-return] async def start_exchange_task( self, finished_cb: Callable[[Credentials | None], Awaitable[None]] @@ -131,10 +131,13 @@ def get_feature_access(hass: HomeAssistant) -> FeatureAccess: """Return the desired calendar feature access.""" # This may be called during config entry setup without integration setup running when there # is no google entry in configuration.yaml - return ( - hass.data.get(DOMAIN, {}) - .get(DATA_CONFIG, {}) - .get(CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS) + return cast( + FeatureAccess, + ( + hass.data.get(DOMAIN, {}) + .get(DATA_CONFIG, {}) + .get(CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS) + ), ) @@ -157,7 +160,7 @@ async def async_create_device_flow( return DeviceFlow(hass, oauth_flow, device_flow_info) -class ApiAuthImpl(AbstractAuth): +class ApiAuthImpl(AbstractAuth): # type: ignore[misc] """Authentication implementation for google calendar api library.""" def __init__( @@ -172,10 +175,10 @@ class ApiAuthImpl(AbstractAuth): async def async_get_access_token(self) -> str: """Return a valid access token.""" await self._session.async_ensure_token_valid() - return self._session.token["access_token"] + return cast(str, self._session.token["access_token"]) -class AccessTokenAuthImpl(AbstractAuth): +class AccessTokenAuthImpl(AbstractAuth): # type: ignore[misc] """Authentication implementation used during config flow, without refresh. This exists to allow the config flow to use the API before it has fully diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index b0d13c0c0c6..01780702b7f 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -1,4 +1,5 @@ """Support for Google Calendar Search binary sensors.""" + from __future__ import annotations import copy @@ -89,14 +90,25 @@ def _async_setup_entities( ) -> None: calendar_service = hass.data[DOMAIN][DATA_SERVICE] entities = [] + num_entities = len(disc_info[CONF_ENTITIES]) for data in disc_info[CONF_ENTITIES]: - if not data[CONF_TRACK]: - continue - entity_id = generate_entity_id( - ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass - ) + entity_enabled = data.get(CONF_TRACK, True) + entity_name = data[CONF_DEVICE_ID] + entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass) + calendar_id = disc_info[CONF_CAL_ID] + if num_entities > 1: + # The google_calendars.yaml file lets users add multiple entities for + # the same calendar id and needs additional disambiguation + unique_id = f"{calendar_id}-{entity_name}" + else: + unique_id = calendar_id entity = GoogleCalendarEntity( - calendar_service, disc_info[CONF_CAL_ID], data, entity_id + calendar_service, + disc_info[CONF_CAL_ID], + data, + entity_id, + unique_id, + entity_enabled, ) entities.append(entity) @@ -112,6 +124,8 @@ class GoogleCalendarEntity(CalendarEntity): calendar_id: str, data: dict[str, Any], entity_id: str, + unique_id: str, + entity_enabled: bool, ) -> None: """Create the Calendar event device.""" self._calendar_service = calendar_service @@ -123,6 +137,8 @@ class GoogleCalendarEntity(CalendarEntity): 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 @property def extra_state_attributes(self) -> dict[str, bool]: @@ -152,7 +168,7 @@ class GoogleCalendarEntity(CalendarEntity): """Return True if the event is visible.""" if self._ignore_availability: return True - return event.transparency == OPAQUE + return event.transparency == OPAQUE # type: ignore[no-any-return] async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 3b513f17197..be516230d2b 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -7,7 +7,10 @@ from typing import Any from gcal_sync.api import GoogleCalendarService from gcal_sync.exceptions import ApiException from oauth2client.client import Credentials +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -21,7 +24,7 @@ from .api import ( async_create_device_flow, get_feature_access, ) -from .const import DOMAIN +from .const import CONF_CALENDAR_ACCESS, DOMAIN, FeatureAccess _LOGGER = logging.getLogger(__name__) @@ -36,7 +39,7 @@ class OAuth2FlowHandler( def __init__(self) -> None: """Set up instance.""" super().__init__() - self._reauth = False + self._reauth_config_entry: config_entries.ConfigEntry | None = None self._device_flow: DeviceFlow | None = None @property @@ -60,7 +63,7 @@ class OAuth2FlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle external yaml configuration.""" - if not self._reauth and self._async_current_entries(): + if not self._reauth_config_entry and self._async_current_entries(): return self.async_abort(reason="already_configured") return await super().async_step_user(user_input) @@ -84,12 +87,17 @@ class OAuth2FlowHandler( self.flow_impl, ) return self.async_abort(reason="oauth_error") + calendar_access = get_feature_access(self.hass) + if self._reauth_config_entry and self._reauth_config_entry.options: + calendar_access = FeatureAccess[ + self._reauth_config_entry.options[CONF_CALENDAR_ACCESS] + ] try: device_flow = await async_create_device_flow( self.hass, self.flow_impl.client_id, self.flow_impl.client_secret, - get_feature_access(self.hass), + calendar_access, ) except OAuthError as err: _LOGGER.error("Error initializing device flow: %s", str(err)) @@ -146,13 +154,21 @@ class OAuth2FlowHandler( _LOGGER.debug("Error reading calendar primary calendar: %s", err) primary_calendar = None title = primary_calendar.id if primary_calendar else self.flow_impl.name - return self.async_create_entry(title=title, data=data) + return self.async_create_entry( + title=title, + data=data, + options={ + CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name, + }, + ) async def async_step_reauth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._reauth = True + self._reauth_config_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -162,3 +178,43 @@ class OAuth2FlowHandler( if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create an options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Google Calendar options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_CALENDAR_ACCESS, + default=self.config_entry.options.get(CONF_CALENDAR_ACCESS), + ): vol.In( + { + "read_write": "Read/Write access (can create events)", + "read_only": "Read-only access", + } + ) + } + ), + ) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index e8ec7091030..e32223627be 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -27,5 +27,14 @@ "progress": { "exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}" } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "Home Assistant access to Google Calendar" + } + } + } } } diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json index 02c8e6d7029..58c89834ca5 100644 --- a/homeassistant/components/google/translations/en.json +++ b/homeassistant/components/google/translations/en.json @@ -27,5 +27,14 @@ "title": "Reauthenticate Integration" } } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "Home Assistant access to Google Calendar" + } + } + } } } \ No newline at end of file diff --git a/mypy.ini b/mypy.ini index ed073141fe1..3cc9653e27a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -874,6 +874,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 625d1faa937..77ebe1e56cd 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -21,6 +21,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.google.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.dt import utcnow @@ -143,6 +144,7 @@ async def test_full_flow_yaml_creds( "token_type": "Bearer", }, } + assert result.get("options") == {"calendar_access": "read_write"} assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) @@ -205,6 +207,7 @@ async def test_full_flow_application_creds( "token_type": "Bearer", }, } + assert result.get("options") == {"calendar_access": "read_write"} assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) @@ -441,7 +444,12 @@ async def test_reauth_flow( assert await component_setup() result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -523,3 +531,66 @@ async def test_title_lookup_failure( assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 + + +async def test_options_flow_triggers_reauth( + hass: HomeAssistant, + component_setup: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test load and unload of a ConfigEntry.""" + config_entry.add_to_hass(hass) + await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == {"calendar_access": "read_write"} + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + data_schema = result["data_schema"].schema + assert set(data_schema) == {"calendar_access"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "calendar_access": "read_only", + }, + ) + assert result["type"] == "create_entry" + + await hass.async_block_till_done() + assert config_entry.options == {"calendar_access": "read_only"} + # Re-auth flow was initiated because access level changed + assert config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_options_flow_no_changes( + hass: HomeAssistant, + component_setup: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test load and unload of a ConfigEntry.""" + config_entry.add_to_hass(hass) + await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == {"calendar_access": "read_write"} + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "calendar_access": "read_write", + }, + ) + assert result["type"] == "create_entry" + + await hass.async_block_till_done() + assert config_entry.options == {"calendar_access": "read_write"} + # Re-auth flow was initiated because access level changed + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 511e8545b40..4709379e840 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -46,7 +46,7 @@ HassApi = Callable[[], Awaitable[dict[str, Any]]] def assert_state(actual: State | None, expected: State | None) -> None: """Assert that the two states are equal.""" - if actual is None: + if actual is None or expected is None: assert actual == expected return assert actual.entity_id == expected.entity_id