diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py
index 87d76b9f4c8..f158db884dc 100644
--- a/homeassistant/components/google/__init__.py
+++ b/homeassistant/components/google/__init__.py
@@ -1,23 +1,20 @@
"""Support for Google - Calendar Event Devices."""
from __future__ import annotations
+import asyncio
from collections.abc import Mapping
-from datetime import datetime, timedelta, timezone
-from enum import Enum
+from datetime import datetime, timedelta
import logging
from typing import Any
-from oauth2client.client import (
- FlowExchangeError,
- OAuth2DeviceCodeError,
- OAuth2WebServerFlow,
-)
+from httplib2.error import ServerNotFoundError
from oauth2client.file import Storage
import voluptuous as vol
from voluptuous.error import Error as VoluptuousError
import yaml
-from homeassistant.components import persistent_notification
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
@@ -25,20 +22,28 @@ from homeassistant.const import (
CONF_ENTITIES,
CONF_NAME,
CONF_OFFSET,
- Platform,
)
-from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
-from homeassistant.helpers import discovery
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
+from homeassistant.helpers import config_entry_oauth2_flow
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import generate_entity_id
-from homeassistant.helpers.event import track_utc_time_change
from homeassistant.helpers.typing import ConfigType
-from .api import GoogleCalendarService
+from . import config_flow
+from .api import DeviceAuth, GoogleCalendarService
+from .const import (
+ CONF_CALENDAR_ACCESS,
+ DATA_CONFIG,
+ DATA_SERVICE,
+ DISCOVER_CALENDAR,
+ DOMAIN,
+ FeatureAccess,
+)
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "google"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_TRACK_NEW = "track_new_calendar"
@@ -48,7 +53,6 @@ CONF_TRACK = "track"
CONF_SEARCH = "search"
CONF_IGNORE_AVAILABILITY = "ignore_availability"
CONF_MAX_RESULTS = "max_results"
-CONF_CALENDAR_ACCESS = "calendar_access"
DEFAULT_CONF_OFFSET = "!!"
@@ -74,27 +78,11 @@ SERVICE_SCAN_CALENDARS = "scan_for_calendars"
SERVICE_FOUND_CALENDARS = "found_calendar"
SERVICE_ADD_EVENT = "add_event"
-DATA_SERVICE = "service"
-
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
TOKEN_FILE = f".{DOMAIN}.token"
-
-class FeatureAccess(Enum):
- """Class to represent different access scopes."""
-
- read_only = "https://www.googleapis.com/auth/calendar.readonly"
- read_write = "https://www.googleapis.com/auth/calendar"
-
- def __init__(self, scope: str) -> None:
- """Init instance."""
- self._scope = scope
-
- @property
- def scope(self) -> str:
- """Google calendar scope for the feature."""
- return self._scope
+PLATFORMS = ["calendar"]
CONFIG_SCHEMA = vol.Schema(
@@ -159,131 +147,79 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
)
-def do_authentication(
- hass: HomeAssistant,
- hass_config: ConfigType,
- config: ConfigType,
- storage: Storage,
-) -> bool:
- """Notify user of actions and authenticate.
-
- Notify user of user_code and verification_url then poll
- until we have an access token.
- """
- oauth = OAuth2WebServerFlow(
- client_id=config[CONF_CLIENT_ID],
- client_secret=config[CONF_CLIENT_SECRET],
- scope=config[CONF_CALENDAR_ACCESS].scope,
- redirect_uri="Home-Assistant.io",
- )
- try:
- dev_flow = oauth.step1_get_device_and_user_codes()
- except OAuth2DeviceCodeError as err:
- persistent_notification.create(
- hass,
- f"Error: {err}
You will need to restart hass after fixing." "",
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- return False
-
- persistent_notification.create(
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Google component."""
+ conf = config.get(DOMAIN, {})
+ hass.data[DOMAIN] = {DATA_CONFIG: conf}
+ config_flow.OAuth2FlowHandler.async_register_implementation(
hass,
- (
- f"In order to authorize Home-Assistant to view your calendars "
- f'you must visit: {dev_flow.verification_url} and enter '
- f"code: {dev_flow.user_code}"
- ),
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
-
- listener: CALLBACK_TYPE | None = None
-
- def step2_exchange(now: datetime) -> None:
- """Keep trying to validate the user_code until it expires."""
- _LOGGER.debug("Attempting to validate user code")
-
- # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
- # object without tzinfo. For the comparison below to work, it needs one.
- user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc)
-
- if now >= user_code_expiry:
- persistent_notification.create(
- hass,
- "Authentication code expired, please restart "
- "Home-Assistant and try again",
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
- assert listener
- listener()
- return
-
- try:
- credentials = oauth.step2_exchange(device_flow_info=dev_flow)
- except FlowExchangeError:
- # not ready yet, call again
- return
-
- storage.put(credentials)
- do_setup(hass, hass_config, config)
- assert listener
- listener()
- persistent_notification.create(
+ DeviceAuth(
hass,
- (
- f"We are all setup now. Check {YAML_DEVICES} for calendars that have "
- f"been found"
- ),
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
-
- listener = track_utc_time_change(
- hass, step2_exchange, second=range(1, 60, dev_flow.interval)
+ conf[CONF_CLIENT_ID],
+ conf[CONF_CLIENT_SECRET],
+ ),
)
- return True
-
-
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Google platform."""
-
- if not (conf := config.get(DOMAIN, {})):
- # component is set up by tts platform
- return True
-
+ # Import credentials from the old token file into the new way as
+ # a ConfigEntry managed by home assistant.
storage = Storage(hass.config.path(TOKEN_FILE))
- hass.data[DOMAIN] = {
- DATA_SERVICE: GoogleCalendarService(hass, storage),
- }
- creds = storage.get()
- if (
- not creds
- or not creds.scopes
- or conf[CONF_CALENDAR_ACCESS].scope not in creds.scopes
- ):
- do_authentication(hass, config, conf, storage)
- else:
- do_setup(hass, config, conf)
+ creds = await hass.async_add_executor_job(storage.get)
+ if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes:
+ _LOGGER.debug("Importing configuration entry with credentials")
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={
+ "creds": creds,
+ },
+ )
+ )
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Google from a config entry."""
+ implementation = (
+ await config_entry_oauth2_flow.async_get_config_entry_implementation(
+ hass, entry
+ )
+ )
+ assert isinstance(implementation, DeviceAuth)
+ session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
+ required_scope = hass.data[DOMAIN][DATA_CONFIG][CONF_CALENDAR_ACCESS].scope
+ if required_scope not in session.token.get("scope", []):
+ raise ConfigEntryAuthFailed(
+ "Required scopes are not available, reauth required"
+ )
+ calendar_service = GoogleCalendarService(hass, session)
+ hass.data[DOMAIN][DATA_SERVICE] = calendar_service
+
+ await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service)
+
+ hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
-def setup_services(
+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_setup_services(
hass: HomeAssistant,
- hass_config: ConfigType,
config: ConfigType,
calendar_service: GoogleCalendarService,
) -> None:
"""Set up the service listeners."""
created_calendars = set()
- calendars = load_config(hass.config.path(YAML_DEVICES))
+ calendars = await hass.async_add_executor_job(
+ load_config, hass.config.path(YAML_DEVICES)
+ )
- def _found_calendar(call: ServiceCall) -> None:
- """Check if we know about a calendar and generate PLATFORM_DISCOVER."""
+ async def _found_calendar(call: ServiceCall) -> None:
calendar = get_calendar_info(hass, call.data)
calendar_id = calendar[CONF_CAL_ID]
@@ -294,31 +230,33 @@ def setup_services(
# Populate the yaml file with all discovered calendars
if calendar_id not in calendars:
calendars[calendar_id] = calendar
- update_config(hass.config.path(YAML_DEVICES), calendar)
+ await hass.async_add_executor_job(
+ update_config, hass.config.path(YAML_DEVICES), calendar
+ )
else:
# Prefer entity/name information from yaml, overriding api
calendar = calendars[calendar_id]
+ async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar)
- discovery.load_platform(
- hass,
- Platform.CALENDAR,
- DOMAIN,
- calendar,
- hass_config,
- )
+ hass.services.async_register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
- hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
-
- def _scan_for_calendars(call: ServiceCall) -> None:
+ async def _scan_for_calendars(call: ServiceCall) -> None:
"""Scan for new calendars."""
- calendars = calendar_service.list_calendars()
+ try:
+ calendars = await calendar_service.async_list_calendars()
+ except ServerNotFoundError as err:
+ raise HomeAssistantError(str(err)) from err
+ tasks = []
for calendar in calendars:
calendar[CONF_TRACK] = config[CONF_TRACK_NEW]
- hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
+ tasks.append(
+ hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
+ )
+ await asyncio.gather(*tasks)
- hass.services.register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
+ hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
- def _add_event(call: ServiceCall) -> None:
+ async def _add_event(call: ServiceCall) -> None:
"""Add a new event to calendar."""
start = {}
end = {}
@@ -354,7 +292,7 @@ def setup_services(
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
- calendar_service.create_event(
+ await calendar_service.async_create_event(
call.data[EVENT_CALENDAR_ID],
{
"summary": call.data[EVENT_SUMMARY],
@@ -366,20 +304,11 @@ def setup_services(
# Only expose the add event service if we have the correct permissions
if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
- hass.services.register(
+ hass.services.async_register(
DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
)
-def do_setup(hass: HomeAssistant, hass_config: ConfigType, config: ConfigType) -> None:
- """Run the setup after we have everything configured."""
- calendar_service = hass.data[DOMAIN][DATA_SERVICE]
- setup_services(hass, hass_config, config, calendar_service)
-
- # Fetch calendars from the API
- hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None)
-
-
def get_calendar_info(
hass: HomeAssistant, calendar: Mapping[str, Any]
) -> dict[str, Any]:
diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py
index 1de6eb4e9aa..c279aefef88 100644
--- a/homeassistant/components/google/api.py
+++ b/homeassistant/components/google/api.py
@@ -2,20 +2,166 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable
import datetime
import logging
from typing import Any
from googleapiclient import discovery as google_discovery
-from oauth2client.file import Storage
+import oauth2client
+from oauth2client.client import (
+ Credentials,
+ DeviceFlowInfo,
+ FlowExchangeError,
+ OAuth2Credentials,
+ OAuth2DeviceCodeError,
+ OAuth2WebServerFlow,
+)
-from homeassistant.core import HomeAssistant
+from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant
+from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import dt
+from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN
+
_LOGGER = logging.getLogger(__name__)
-
EVENT_PAGE_SIZE = 100
+EXCHANGE_TIMEOUT_SECONDS = 60
+
+
+class OAuthError(Exception):
+ """OAuth related error."""
+
+
+class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
+ """OAuth implementation for Device Auth."""
+
+ def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None:
+ """Initialize InstalledAppAuth."""
+ super().__init__(
+ hass,
+ DEVICE_AUTH_IMPL,
+ client_id,
+ client_secret,
+ oauth2client.GOOGLE_AUTH_URI,
+ oauth2client.GOOGLE_TOKEN_URI,
+ )
+
+ async def async_resolve_external_data(self, external_data: Any) -> dict:
+ """Resolve a Google API Credentials object to Home Assistant token."""
+ creds: Credentials = external_data["creds"]
+ return {
+ "access_token": creds.access_token,
+ "refresh_token": creds.refresh_token,
+ "scope": " ".join(creds.scopes),
+ "token_type": "Bearer",
+ "expires_in": creds.token_expiry.timestamp(),
+ }
+
+
+class DeviceFlow:
+ """OAuth2 device flow for exchanging a code for an access token."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ oauth_flow: OAuth2WebServerFlow,
+ device_flow_info: DeviceFlowInfo,
+ ) -> None:
+ """Initialize DeviceFlow."""
+ self._hass = hass
+ self._oauth_flow = oauth_flow
+ self._device_flow_info: DeviceFlowInfo = device_flow_info
+ self._exchange_task_unsub: CALLBACK_TYPE | None = None
+
+ @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
+
+ @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
+
+ async def start_exchange_task(
+ self, finished_cb: Callable[[Credentials | None], Awaitable[None]]
+ ) -> None:
+ """Start the device auth exchange flow polling.
+
+ The callback is invoked with the valid credentials or with None on timeout.
+ """
+ _LOGGER.debug("Starting exchange flow")
+ assert not self._exchange_task_unsub
+ max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS)
+ # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
+ # object without tzinfo. For the comparison below to work, it needs one.
+ user_code_expiry = self._device_flow_info.user_code_expiry.replace(
+ tzinfo=datetime.timezone.utc
+ )
+ expiration_time = min(user_code_expiry, max_timeout)
+
+ def _exchange() -> Credentials:
+ return self._oauth_flow.step2_exchange(
+ device_flow_info=self._device_flow_info
+ )
+
+ async def _poll_attempt(now: datetime.datetime) -> None:
+ assert self._exchange_task_unsub
+ _LOGGER.debug("Attempting OAuth code exchange")
+ # Note: The callback is invoked with None when the device code has expired
+ creds: Credentials | None = None
+ if now < expiration_time:
+ try:
+ creds = await self._hass.async_add_executor_job(_exchange)
+ except FlowExchangeError:
+ _LOGGER.debug("Token not yet ready; trying again later")
+ return
+ self._exchange_task_unsub()
+ self._exchange_task_unsub = None
+ await finished_cb(creds)
+
+ self._exchange_task_unsub = async_track_time_interval(
+ self._hass,
+ _poll_attempt,
+ datetime.timedelta(seconds=self._device_flow_info.interval),
+ )
+
+
+async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow:
+ """Create a new Device flow."""
+ conf = hass.data[DOMAIN][DATA_CONFIG]
+ oauth_flow = OAuth2WebServerFlow(
+ client_id=conf[CONF_CLIENT_ID],
+ client_secret=conf[CONF_CLIENT_SECRET],
+ scope=conf[CONF_CALENDAR_ACCESS].scope,
+ redirect_uri="",
+ )
+ try:
+ device_flow_info = await hass.async_add_executor_job(
+ oauth_flow.step1_get_device_and_user_codes
+ )
+ except OAuth2DeviceCodeError as err:
+ raise OAuthError(str(err)) from err
+ return DeviceFlow(hass, oauth_flow, device_flow_info)
+
+
+def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentials:
+ """Convert a Home Assistant token to a Google API Credentials object."""
+ conf = hass.data[DOMAIN][DATA_CONFIG]
+ return OAuth2Credentials(
+ access_token=token["access_token"],
+ client_id=conf[CONF_CLIENT_ID],
+ client_secret=conf[CONF_CLIENT_SECRET],
+ refresh_token=token["refresh_token"],
+ token_expiry=token["expires_at"],
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ scopes=[conf[CONF_CALENDAR_ACCESS].scope],
+ user_agent=None,
+ )
def _api_time_format(time: datetime.datetime | None) -> str | None:
@@ -26,26 +172,44 @@ def _api_time_format(time: datetime.datetime | None) -> str | None:
class GoogleCalendarService:
"""Calendar service interface to Google."""
- def __init__(self, hass: HomeAssistant, storage: Storage) -> None:
+ def __init__(
+ self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session
+ ) -> None:
"""Init the Google Calendar service."""
self._hass = hass
- self._storage = storage
+ self._session = session
- def _get_service(self) -> google_discovery.Resource:
- """Get the calendar service from the storage file token."""
+ async def _async_get_service(self) -> google_discovery.Resource:
+ """Get the calendar service with valid credetnails."""
+ await self._session.async_ensure_token_valid()
+ creds = _async_google_creds(self._hass, self._session.token)
return google_discovery.build(
- "calendar", "v3", credentials=self._storage.get(), cache_discovery=False
+ "calendar", "v3", credentials=creds, cache_discovery=False
)
- def list_calendars(self) -> list[dict[str, Any]]:
+ async def async_list_calendars(
+ self,
+ ) -> list[dict[str, Any]]:
"""Return the list of calendars the user has added to their list."""
- cal_list = self._get_service().calendarList() # pylint: disable=no-member
- return cal_list.list().execute()["items"]
+ service = await self._async_get_service()
- def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]:
- """Create an event."""
- events = self._get_service().events() # pylint: disable=no-member
- return events.insert(calendarId=calendar_id, body=event).execute()
+ def _list_calendars() -> list[dict[str, Any]]:
+ cal_list = service.calendarList() # pylint: disable=no-member
+ return cal_list.list().execute()["items"]
+
+ return await self._hass.async_add_executor_job(_list_calendars)
+
+ async def async_create_event(
+ self, calendar_id: str, event: dict[str, Any]
+ ) -> dict[str, Any]:
+ """Return the list of calendars the user has added to their list."""
+ service = await self._async_get_service()
+
+ def _create_event() -> dict[str, Any]:
+ events = service.events() # pylint: disable=no-member
+ return events.insert(calendarId=calendar_id, body=event).execute()
+
+ return await self._hass.async_add_executor_job(_create_event)
async def async_list_events(
self,
@@ -56,33 +220,20 @@ class GoogleCalendarService:
page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Return the list of events."""
- return await self._hass.async_add_executor_job(
- self.list_events,
- calendar_id,
- start_time,
- end_time,
- search,
- page_token,
- )
+ service = await self._async_get_service()
- def list_events(
- self,
- calendar_id: str,
- start_time: datetime.datetime | None = None,
- end_time: datetime.datetime | None = None,
- search: str | None = None,
- page_token: str | None = None,
- ) -> tuple[list[dict[str, Any]], str | None]:
- """Return the list of events."""
- events = self._get_service().events() # pylint: disable=no-member
- result = events.list(
- calendarId=calendar_id,
- timeMin=_api_time_format(start_time if start_time else dt.now()),
- timeMax=_api_time_format(end_time),
- q=search,
- maxResults=EVENT_PAGE_SIZE,
- pageToken=page_token,
- singleEvents=True, # Flattens recurring events
- orderBy="startTime",
- ).execute()
- return (result["items"], result.get("nextPageToken"))
+ def _list_events() -> tuple[list[dict[str, Any]], str | None]:
+ events = service.events() # pylint: disable=no-member
+ result = events.list(
+ calendarId=calendar_id,
+ timeMin=_api_time_format(start_time if start_time else dt.now()),
+ timeMax=_api_time_format(end_time),
+ q=search,
+ maxResults=EVENT_PAGE_SIZE,
+ pageToken=page_token,
+ singleEvents=True, # Flattens recurring events
+ orderBy="startTime",
+ ).execute()
+ return (result["items"], result.get("nextPageToken"))
+
+ return await self._hass.async_add_executor_job(_list_events)
diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py
index 282c80988e4..666096fd8c3 100644
--- a/homeassistant/components/google/calendar.py
+++ b/homeassistant/components/google/calendar.py
@@ -14,11 +14,13 @@ from homeassistant.components.calendar import (
calculate_offset,
is_offset_reached,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from . import (
@@ -29,8 +31,10 @@ from . import (
DATA_SERVICE,
DEFAULT_CONF_OFFSET,
DOMAIN,
+ SERVICE_SCAN_CALENDARS,
)
from .api import GoogleCalendarService
+from .const import DISCOVER_CALENDAR
_LOGGER = logging.getLogger(__name__)
@@ -48,19 +52,39 @@ TRANSPARENCY = "transparency"
OPAQUE = "opaque"
-def setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- add_entities: AddEntitiesCallback,
- disc_info: DiscoveryInfoType | None = None,
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
- """Set up the calendar platform for event devices."""
- if disc_info is None:
- return
+ """Set up the google calendar platform."""
- if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]):
- return
+ @callback
+ def async_discover(discovery_info: dict[str, Any]) -> None:
+ _async_setup_entities(
+ hass,
+ entry,
+ async_add_entities,
+ discovery_info,
+ )
+ entry.async_on_unload(
+ async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover)
+ )
+
+ # Look for any new calendars
+ try:
+ await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True)
+ except HomeAssistantError as err:
+ # This can happen if there's a connection error during setup.
+ raise PlatformNotReady(str(err)) from err
+
+
+@callback
+def _async_setup_entities(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+ disc_info: dict[str, Any],
+) -> None:
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
entities = []
for data in disc_info[CONF_ENTITIES]:
@@ -74,7 +98,7 @@ def setup_platform(
)
entities.append(entity)
- add_entities(entities, True)
+ async_add_entities(entities, True)
class GoogleCalendarEventDevice(CalendarEventDevice):
@@ -144,10 +168,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
return event_list
@Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self) -> None:
+ async def async_update(self) -> None:
"""Get the latest data."""
try:
- items, _ = self._calendar_service.list_events(
+ items, _ = await self._calendar_service.async_list_events(
self._calendar_id, search=self._search
)
except ServerNotFoundError as err:
diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py
new file mode 100644
index 00000000000..c70dd83fcae
--- /dev/null
+++ b/homeassistant/components/google/config_flow.py
@@ -0,0 +1,128 @@
+"""Config flow for Google integration."""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from oauth2client.client import Credentials
+
+from homeassistant.data_entry_flow import FlowResult
+from homeassistant.helpers import config_entry_oauth2_flow
+
+from .api import DeviceFlow, OAuthError, async_create_device_flow
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle Google Calendars OAuth2 authentication."""
+
+ DOMAIN = DOMAIN
+
+ def __init__(self) -> None:
+ """Set up instance."""
+ super().__init__()
+ self._reauth = False
+ self._device_flow: DeviceFlow | None = None
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
+
+ async def async_step_import(self, info: dict[str, Any]) -> FlowResult:
+ """Import existing auth from Nest."""
+ if self._async_current_entries():
+ return self.async_abort(reason="single_instance_allowed")
+ implementations = await config_entry_oauth2_flow.async_get_implementations(
+ self.hass, self.DOMAIN
+ )
+ assert len(implementations) == 1
+ self.flow_impl = list(implementations.values())[0]
+ self.external_data = info
+ return await super().async_step_creation(info)
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle external yaml configuration."""
+ if not self._reauth and self._async_current_entries():
+ return self.async_abort(reason="already_configured")
+ return await super().async_step_user(user_input)
+
+ async def async_step_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Create an entry for auth."""
+ # The default behavior from the parent class is to redirect the
+ # user with an external step. When using the device flow, we instead
+ # prompt the user to visit a URL and enter a code. The device flow
+ # background task will poll the exchange endpoint to get valid
+ # creds or until a timeout is complete.
+ if user_input is not None:
+ return self.async_show_progress_done(next_step_id="creation")
+
+ if not self._device_flow:
+ _LOGGER.debug("Creating DeviceAuth flow")
+ try:
+ device_flow = await async_create_device_flow(self.hass)
+ except OAuthError as err:
+ _LOGGER.error("Error initializing device flow: %s", str(err))
+ return self.async_abort(reason="oauth_error")
+ self._device_flow = device_flow
+
+ async def _exchange_finished(creds: Credentials | None) -> None:
+ self.external_data = {"creds": creds} # is None on timeout/expiration
+ self.hass.async_create_task(
+ self.hass.config_entries.flow.async_configure(
+ flow_id=self.flow_id, user_input={}
+ )
+ )
+
+ await device_flow.start_exchange_task(_exchange_finished)
+
+ return self.async_show_progress(
+ step_id="auth",
+ description_placeholders={
+ "url": self._device_flow.verification_url,
+ "user_code": self._device_flow.user_code,
+ },
+ progress_action="exchange",
+ )
+
+ async def async_step_creation(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Handle external yaml configuration."""
+ if self.external_data.get("creds") is None:
+ return self.async_abort(reason="code_expired")
+ return await super().async_step_creation(user_input)
+
+ async def async_oauth_create_entry(self, data: dict) -> FlowResult:
+ """Create an entry for the flow, or update existing entry."""
+ existing_entries = self._async_current_entries()
+ if existing_entries:
+ assert len(existing_entries) == 1
+ entry = existing_entries[0]
+ self.hass.config_entries.async_update_entry(entry, data=data)
+ await self.hass.config_entries.async_reload(entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
+ return self.async_create_entry(title=self.flow_impl.name, data=data)
+
+ async def async_step_reauth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Perform reauth upon an API authentication error."""
+ self._reauth = True
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ return await self.async_step_user()
diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py
new file mode 100644
index 00000000000..d5cdabb0638
--- /dev/null
+++ b/homeassistant/components/google/const.py
@@ -0,0 +1,30 @@
+"""Constants for google integration."""
+from __future__ import annotations
+
+from enum import Enum
+
+DOMAIN = "google"
+DEVICE_AUTH_IMPL = "device_auth"
+
+CONF_CALENDAR_ACCESS = "calendar_access"
+DATA_CALENDARS = "calendars"
+DATA_SERVICE = "service"
+DATA_CONFIG = "config"
+
+DISCOVER_CALENDAR = "google_discover_calendar"
+
+
+class FeatureAccess(Enum):
+ """Class to represent different access scopes."""
+
+ read_only = "https://www.googleapis.com/auth/calendar.readonly"
+ read_write = "https://www.googleapis.com/auth/calendar"
+
+ def __init__(self, scope: str) -> None:
+ """Init instance."""
+ self._scope = scope
+
+ @property
+ def scope(self) -> str:
+ """Google calendar scope for the feature."""
+ return self._scope
diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json
index 53b85ef7aa2..589ecb25b21 100644
--- a/homeassistant/components/google/manifest.json
+++ b/homeassistant/components/google/manifest.json
@@ -1,6 +1,8 @@
{
"domain": "google",
"name": "Google Calendars",
+ "config_flow": true,
+ "dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
"requirements": [
"google-api-python-client==2.38.0",
diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json
new file mode 100644
index 00000000000..8208b7bebc9
--- /dev/null
+++ b/homeassistant/components/google/strings.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "step": {
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Nest integration needs to re-authenticate your account"
+ },
+ "auth": {
+ "title": "Link Google Account"
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "code_expired": "Authentication code expired or credential setup is invalid, please try again.",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]"
+ },
+ "create_entry": {
+ "default": "[%key:common::config_flow::create_entry::authenticated%]"
+ },
+ "progress": {
+ "exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
+ }
+ }
+}
diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json
new file mode 100644
index 00000000000..51d1ad9aab8
--- /dev/null
+++ b/homeassistant/components/google/translations/en.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Account is already configured",
+ "already_in_progress": "Configuration flow is already in progress",
+ "code_expired": "Authentication code expired, please try again.",
+ "invalid_access_token": "Invalid access token",
+ "missing_configuration": "The component is not configured. Please follow the documentation.",
+ "oauth_error": "Received invalid token data.",
+ "reauth_successful": "Re-authentication was successful"
+ },
+ "create_entry": {
+ "default": "Successfully authenticated"
+ },
+ "progress": {
+ "exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
+ },
+ "step": {
+ "auth": {
+ "title": "Link Google Account"
+ },
+ "pick_implementation": {
+ "title": "Pick Authentication Method"
+ },
+ "reauth_confirm": {
+ "description": "The Nest integration needs to re-authenticate your account",
+ "title": "Reauthenticate Integration"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 99e5cf7caf3..6d572efb0c7 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -125,6 +125,7 @@ FLOWS = [
"goalzero",
"gogogate2",
"goodwe",
+ "google",
"google_travel_time",
"gpslogger",
"gree",
diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py
index f3d8f9a28b9..8efeac9983d 100644
--- a/tests/components/google/conftest.py
+++ b/tests/components/google/conftest.py
@@ -18,6 +18,8 @@ from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.dt import utcnow
+from tests.common import MockConfigEntry
+
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
ApiResult = Callable[[dict[str, Any]], None]
@@ -156,6 +158,25 @@ async def storage() -> YieldFixture[FakeStorage]:
yield storage
+@pytest.fixture
+async def config_entry(token_scopes: list[str]) -> MockConfigEntry:
+ """Fixture to create a config entry for the integration."""
+ token_expiry = utcnow() + datetime.timedelta(days=7)
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "auth_implementation": "device_auth",
+ "token": {
+ "access_token": "ACCESS_TOKEN",
+ "refresh_token": "REFRESH_TOKEN",
+ "scope": " ".join(token_scopes),
+ "token_type": "Bearer",
+ "expires_at": token_expiry.timestamp(),
+ },
+ },
+ )
+
+
@pytest.fixture
async def mock_token_read(
hass: HomeAssistant,
diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py
index 34227ae02b1..baa09d0b88f 100644
--- a/tests/components/google/test_calendar.py
+++ b/tests/components/google/test_calendar.py
@@ -53,10 +53,15 @@ TEST_EVENT = {
@pytest.fixture(autouse=True)
def mock_test_setup(
- mock_calendars_yaml, test_api_calendar, mock_calendars_list, mock_token_read
+ hass,
+ mock_calendars_yaml,
+ test_api_calendar,
+ mock_calendars_list,
+ config_entry,
):
"""Fixture that pulls in the default fixtures for tests in this file."""
mock_calendars_list({"items": [test_api_calendar]})
+ config_entry.add_to_hass(hass)
return
@@ -300,12 +305,11 @@ async def test_update_error(
assert state.name == TEST_ENTITY_NAME
assert state.state == "on"
- # Advance time to avoid throttling
+ # Advance time beyond update/throttle point
now += datetime.timedelta(minutes=30)
with patch(
"homeassistant.components.google.api.google_discovery.build"
) as mock, patch("homeassistant.util.utcnow", return_value=now):
-
mock.return_value.events.return_value.list.return_value.execute.return_value = {
"items": [
{
@@ -417,3 +421,19 @@ async def test_opaque_event(
assert response.status == HTTPStatus.OK
events = await response.json()
assert (len(events) > 0) == expect_visible_event
+
+
+async def test_scan_calendar_error(
+ hass,
+ calendar_resource,
+ component_setup,
+ test_api_calendar,
+):
+ """Test that the calendar update handles a server error."""
+ with patch(
+ "homeassistant.components.google.api.google_discovery.build",
+ side_effect=httplib2.ServerNotFoundError("unit test"),
+ ):
+ assert await component_setup()
+
+ assert not hass.states.get(TEST_ENTITY)
diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py
new file mode 100644
index 00000000000..a5467b95dcb
--- /dev/null
+++ b/tests/components/google/test_config_flow.py
@@ -0,0 +1,350 @@
+"""Test the google config flow."""
+
+import datetime
+from unittest.mock import Mock, patch
+
+from oauth2client.client import (
+ FlowExchangeError,
+ OAuth2Credentials,
+ OAuth2DeviceCodeError,
+)
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.google.const import DOMAIN
+from homeassistant.core import HomeAssistant
+from homeassistant.util.dt import utcnow
+
+from .conftest import ComponentSetup, YieldFixture
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+CODE_CHECK_INTERVAL = 1
+CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
+
+
+@pytest.fixture(autouse=True)
+async def request_setup(current_request_with_host) -> None:
+ """Request setup."""
+ return
+
+
+@pytest.fixture
+async def code_expiration_delta() -> datetime.timedelta:
+ """Fixture for code expiration time, defaulting to the future."""
+ return datetime.timedelta(minutes=3)
+
+
+@pytest.fixture
+async def mock_code_flow(
+ code_expiration_delta: datetime.timedelta,
+) -> YieldFixture[Mock]:
+ """Fixture for initiating OAuth flow."""
+ with patch(
+ "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
+ ) as mock_flow:
+ mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
+ mock_flow.return_value.interval = CODE_CHECK_INTERVAL
+ yield mock_flow
+
+
+@pytest.fixture
+async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
+ """Fixture for mocking out the exchange for credentials."""
+ with patch(
+ "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
+ ) as mock:
+ yield mock
+
+
+async def fire_alarm(hass, point_in_time):
+ """Fire an alarm and wait for callbacks to run."""
+ with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
+ async_fire_time_changed(hass, point_in_time)
+ await hass.async_block_till_done()
+
+
+async def test_full_flow(
+ hass: HomeAssistant,
+ mock_code_flow: Mock,
+ mock_exchange: Mock,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test successful creds setup."""
+ assert await component_setup()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result.get("type") == "progress"
+ assert result.get("step_id") == "auth"
+ assert "description_placeholders" in result
+ assert "url" in result["description_placeholders"]
+
+ with patch(
+ "homeassistant.components.google.async_setup_entry", return_value=True
+ ) as mock_setup:
+ # Run one tick to invoke the credential exchange check
+ now = utcnow()
+ await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
+ await hass.async_block_till_done()
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"]
+ )
+
+ assert result.get("type") == "create_entry"
+ assert result.get("title") == "Configuration.yaml"
+ assert "data" in result
+ data = result["data"]
+ assert "token" in data
+ data["token"].pop("expires_at")
+ data["token"].pop("expires_in")
+ assert data == {
+ "auth_implementation": "device_auth",
+ "token": {
+ "access_token": "ACCESS_TOKEN",
+ "refresh_token": "REFRESH_TOKEN",
+ "scope": "https://www.googleapis.com/auth/calendar",
+ "token_type": "Bearer",
+ },
+ }
+
+ assert len(mock_setup.mock_calls) == 1
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+
+
+async def test_code_error(
+ hass: HomeAssistant,
+ mock_code_flow: Mock,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test successful creds setup."""
+ assert await component_setup()
+
+ with patch(
+ "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
+ side_effect=OAuth2DeviceCodeError("Test Failure"),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result.get("type") == "abort"
+ assert result.get("reason") == "oauth_error"
+
+
+@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
+async def test_expired_after_exchange(
+ hass: HomeAssistant,
+ mock_code_flow: Mock,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test successful creds setup."""
+ assert await component_setup()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result.get("type") == "progress"
+ assert result.get("step_id") == "auth"
+ assert "description_placeholders" in result
+ assert "url" in result["description_placeholders"]
+
+ # Run one tick to invoke the credential exchange check
+ now = utcnow()
+ await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
+ assert result.get("type") == "abort"
+ assert result.get("reason") == "code_expired"
+
+
+async def test_exchange_error(
+ hass: HomeAssistant,
+ mock_code_flow: Mock,
+ mock_exchange: Mock,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test an error while exchanging the code for credentials."""
+ assert await component_setup()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result.get("type") == "progress"
+ assert result.get("step_id") == "auth"
+ assert "description_placeholders" in result
+ assert "url" in result["description_placeholders"]
+
+ # Run one tick to invoke the credential exchange check
+ now = utcnow()
+ with patch(
+ "oauth2client.client.OAuth2WebServerFlow.step2_exchange",
+ side_effect=FlowExchangeError(),
+ ):
+ now += CODE_CHECK_ALARM_TIMEDELTA
+ await fire_alarm(hass, now)
+ await hass.async_block_till_done()
+
+ # Status has not updated, will retry
+ result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
+ assert result.get("type") == "progress"
+ assert result.get("step_id") == "auth"
+
+ # Run another tick, which attempts credential exchange again
+ with patch(
+ "homeassistant.components.google.async_setup_entry", return_value=True
+ ) as mock_setup:
+ now += CODE_CHECK_ALARM_TIMEDELTA
+ await fire_alarm(hass, now)
+ await hass.async_block_till_done()
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"]
+ )
+
+ assert result.get("type") == "create_entry"
+ assert result.get("title") == "Configuration.yaml"
+ assert "data" in result
+ data = result["data"]
+ assert "token" in data
+ data["token"].pop("expires_at")
+ data["token"].pop("expires_in")
+ assert data == {
+ "auth_implementation": "device_auth",
+ "token": {
+ "access_token": "ACCESS_TOKEN",
+ "refresh_token": "REFRESH_TOKEN",
+ "scope": "https://www.googleapis.com/auth/calendar",
+ "token_type": "Bearer",
+ },
+ }
+
+ assert len(mock_setup.mock_calls) == 1
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+
+
+async def test_existing_config_entry(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test can't configure when config entry already exists."""
+ config_entry.add_to_hass(hass)
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+
+ assert await component_setup()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result.get("type") == "abort"
+ assert result.get("reason") == "already_configured"
+
+
+async def test_missing_configuration(
+ hass: HomeAssistant,
+) -> None:
+ """Test can't configure when config entry already exists."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result.get("type") == "abort"
+ assert result.get("reason") == "missing_configuration"
+
+
+async def test_import_config_entry_from_existing_token(
+ hass: HomeAssistant,
+ mock_token_read: None,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test setup with an existing token file."""
+ assert await component_setup()
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ data = entries[0].data
+ assert "token" in data
+ data["token"].pop("expires_at")
+ data["token"].pop("expires_in")
+ assert data == {
+ "auth_implementation": "device_auth",
+ "token": {
+ "access_token": "ACCESS_TOKEN",
+ "refresh_token": "REFRESH_TOKEN",
+ "scope": "https://www.googleapis.com/auth/calendar",
+ "token_type": "Bearer",
+ },
+ }
+
+
+async def test_reauth_flow(
+ hass: HomeAssistant,
+ mock_code_flow: Mock,
+ mock_exchange: Mock,
+ component_setup: ComponentSetup,
+) -> None:
+ """Test can't configure when config entry already exists."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "auth_implementation": "device_auth",
+ "token": {"access_token": "OLD_ACCESS_TOKEN"},
+ },
+ )
+ config_entry.add_to_hass(hass)
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+
+ assert await component_setup()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "reauth_confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"],
+ user_input={},
+ )
+ assert result.get("type") == "progress"
+ assert result.get("step_id") == "auth"
+ assert "description_placeholders" in result
+ assert "url" in result["description_placeholders"]
+
+ with patch(
+ "homeassistant.components.google.async_setup_entry", return_value=True
+ ) as mock_setup:
+ # Run one tick to invoke the credential exchange check
+ now = utcnow()
+ await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
+ await hass.async_block_till_done()
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"]
+ )
+
+ assert result.get("type") == "abort"
+ assert result.get("reason") == "reauth_successful"
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ data = entries[0].data
+ assert "token" in data
+ data["token"].pop("expires_at")
+ data["token"].pop("expires_in")
+ assert data == {
+ "auth_implementation": "device_auth",
+ "token": {
+ "access_token": "ACCESS_TOKEN",
+ "refresh_token": "REFRESH_TOKEN",
+ "scope": "https://www.googleapis.com/auth/calendar",
+ "token_type": "Bearer",
+ },
+ }
+
+ assert len(mock_setup.mock_calls) == 1
diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py
index 0f02e20c735..6fc575ba03d 100644
--- a/tests/components/google/test_init.py
+++ b/tests/components/google/test_init.py
@@ -6,11 +6,6 @@ import datetime
from typing import Any
from unittest.mock import Mock, call, patch
-from oauth2client.client import (
- FlowExchangeError,
- OAuth2Credentials,
- OAuth2DeviceCodeError,
-)
import pytest
from homeassistant.components.google import (
@@ -18,6 +13,7 @@ from homeassistant.components.google import (
SERVICE_ADD_EVENT,
SERVICE_SCAN_CALENDARS,
)
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, State
from homeassistant.util.dt import utcnow
@@ -30,73 +26,13 @@ from .conftest import (
TEST_YAML_ENTITY_NAME,
ApiResult,
ComponentSetup,
- YieldFixture,
)
-from tests.common import async_fire_time_changed
+from tests.common import MockConfigEntry
# Typing helpers
HassApi = Callable[[], Awaitable[dict[str, Any]]]
-CODE_CHECK_INTERVAL = 1
-CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
-
-
-@pytest.fixture
-async def code_expiration_delta() -> datetime.timedelta:
- """Fixture for code expiration time, defaulting to the future."""
- return datetime.timedelta(minutes=3)
-
-
-@pytest.fixture
-async def mock_code_flow(
- code_expiration_delta: datetime.timedelta,
-) -> YieldFixture[Mock]:
- """Fixture for initiating OAuth flow."""
- with patch(
- "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
- ) as mock_flow:
- mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
- mock_flow.return_value.interval = CODE_CHECK_INTERVAL
- yield mock_flow
-
-
-@pytest.fixture
-async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
- """Fixture for mocking out the exchange for credentials."""
- with patch(
- "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
- ) as mock:
- yield mock
-
-
-@pytest.fixture
-async def mock_notification() -> YieldFixture[Mock]:
- """Fixture for capturing persistent notifications."""
- with patch("homeassistant.components.persistent_notification.create") as mock:
- yield mock
-
-
-async def fire_alarm(hass, point_in_time):
- """Fire an alarm and wait for callbacks to run."""
- with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
- async_fire_time_changed(hass, point_in_time)
- await hass.async_block_till_done()
-
-
-@pytest.mark.parametrize("config", [{}])
-async def test_setup_config_empty(
- hass: HomeAssistant,
- component_setup: ComponentSetup,
- mock_notification: Mock,
-):
- """Test setup component with an empty configuruation."""
- assert await component_setup()
-
- mock_notification.assert_not_called()
-
- assert not hass.states.get(TEST_YAML_ENTITY)
-
def assert_state(actual: State | None, expected: State | None) -> None:
"""Assert that the two states are equal."""
@@ -108,118 +44,29 @@ def assert_state(actual: State | None, expected: State | None) -> None:
assert actual.attributes == expected.attributes
-async def test_init_success(
+@pytest.fixture
+def setup_config_entry(
+ hass: HomeAssistant, config_entry: MockConfigEntry
+) -> MockConfigEntry:
+ """Fixture to initialize the config entry."""
+ config_entry.add_to_hass(hass)
+
+
+async def test_unload_entry(
hass: HomeAssistant,
- mock_code_flow: Mock,
- mock_exchange: Mock,
- mock_notification: Mock,
- mock_calendars_list: ApiResult,
- test_api_calendar: dict[str, Any],
- mock_calendars_yaml: None,
component_setup: ComponentSetup,
+ setup_config_entry: MockConfigEntry,
) -> None:
- """Test successful creds setup."""
- mock_calendars_list({"items": [test_api_calendar]})
- assert await component_setup()
+ """Test load and unload of a ConfigEntry."""
+ await component_setup()
- # Run one tick to invoke the credential exchange check
- now = utcnow()
- await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ entry = entries[0]
+ assert entry.state is ConfigEntryState.LOADED
- state = hass.states.get(TEST_YAML_ENTITY)
- assert state
- assert state.name == TEST_YAML_ENTITY_NAME
- assert state.state == STATE_OFF
-
- mock_notification.assert_called()
- assert "We are all setup now" in mock_notification.call_args[0][1]
-
-
-async def test_code_error(
- hass: HomeAssistant,
- mock_code_flow: Mock,
- component_setup: ComponentSetup,
- mock_notification: Mock,
-) -> None:
- """Test loading the integration with no existing credentials."""
-
- with patch(
- "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
- side_effect=OAuth2DeviceCodeError("Test Failure"),
- ):
- assert await component_setup()
-
- assert not hass.states.get(TEST_YAML_ENTITY)
-
- mock_notification.assert_called()
- assert "Error: Test Failure" in mock_notification.call_args[0][1]
-
-
-@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
-async def test_expired_after_exchange(
- hass: HomeAssistant,
- mock_code_flow: Mock,
- component_setup: ComponentSetup,
- mock_notification: Mock,
-) -> None:
- """Test loading the integration with no existing credentials."""
-
- assert await component_setup()
-
- now = utcnow()
- await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
-
- assert not hass.states.get(TEST_YAML_ENTITY)
-
- mock_notification.assert_called()
- assert (
- "Authentication code expired, please restart Home-Assistant and try again"
- in mock_notification.call_args[0][1]
- )
-
-
-async def test_exchange_error(
- hass: HomeAssistant,
- mock_code_flow: Mock,
- component_setup: ComponentSetup,
- mock_notification: Mock,
-) -> None:
- """Test an error while exchanging the code for credentials."""
-
- with patch(
- "oauth2client.client.OAuth2WebServerFlow.step2_exchange",
- side_effect=FlowExchangeError(),
- ):
- assert await component_setup()
-
- now = utcnow()
- await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
-
- assert not hass.states.get(TEST_YAML_ENTITY)
-
- mock_notification.assert_called()
- assert "In order to authorize Home-Assistant" in mock_notification.call_args[0][1]
-
-
-async def test_existing_token(
- hass: HomeAssistant,
- mock_token_read: None,
- component_setup: ComponentSetup,
- mock_calendars_yaml: None,
- mock_calendars_list: ApiResult,
- test_api_calendar: dict[str, Any],
- mock_notification: Mock,
-) -> None:
- """Test setup with an existing token file."""
- mock_calendars_list({"items": [test_api_calendar]})
- assert await component_setup()
-
- state = hass.states.get(TEST_YAML_ENTITY)
- assert state
- assert state.name == TEST_YAML_ENTITY_NAME
- assert state.state == STATE_OFF
-
- mock_notification.assert_not_called()
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ assert entry.state == ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
@@ -228,80 +75,61 @@ async def test_existing_token(
async def test_existing_token_missing_scope(
hass: HomeAssistant,
token_scopes: list[str],
- mock_token_read: None,
component_setup: ComponentSetup,
- mock_calendars_yaml: None,
- mock_calendars_list: ApiResult,
- test_api_calendar: dict[str, Any],
- mock_notification: Mock,
- mock_code_flow: Mock,
- mock_exchange: Mock,
+ config_entry: MockConfigEntry,
) -> None:
"""Test setup where existing token does not have sufficient scopes."""
- mock_calendars_list({"items": [test_api_calendar]})
+ config_entry.add_to_hass(hass)
assert await component_setup()
- # Run one tick to invoke the credential exchange check
- now = utcnow()
- await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
- assert len(mock_exchange.mock_calls) == 1
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ assert entries[0].state is ConfigEntryState.SETUP_ERROR
- state = hass.states.get(TEST_YAML_ENTITY)
- assert state
- assert state.name == TEST_YAML_ENTITY_NAME
- assert state.state == STATE_OFF
-
- # No notifications on success
- mock_notification.assert_called()
- assert "We are all setup now" in mock_notification.call_args[0][1]
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]["step_id"] == "reauth_confirm"
@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
async def test_calendar_yaml_missing_required_fields(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
- mock_notification: Mock,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test setup with a missing schema fields, ignores the error and continues."""
assert await component_setup()
assert not hass.states.get(TEST_YAML_ENTITY)
- mock_notification.assert_not_called()
-
@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
async def test_invalid_calendar_yaml(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
- mock_notification: Mock,
+ setup_config_entry: MockConfigEntry,
) -> None:
- """Test setup with missing entity id fields fails to setup the integration."""
-
+ """Test setup with missing entity id fields fails to setup the config entry."""
# Integration fails to setup
- assert not await component_setup()
+ assert await component_setup()
+
+ # XXX No config entries
assert not hass.states.get(TEST_YAML_ENTITY)
- mock_notification.assert_not_called()
-
async def test_calendar_yaml_error(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
- mock_notification: Mock,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test setup with yaml file not found."""
-
mock_calendars_list({"items": [test_api_calendar]})
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
@@ -344,12 +172,12 @@ async def test_calendar_yaml_error(
)
async def test_track_new(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_calendars_yaml: None,
expected_state: State,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test behavior of configuration.yaml settings for tracking new calendars not in the config."""
@@ -363,11 +191,11 @@ async def test_track_new(
@pytest.mark.parametrize("calendars_config", [[]])
async def test_found_calendar_from_api(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test finding a calendar from the API."""
@@ -402,13 +230,13 @@ async def test_found_calendar_from_api(
)
async def test_calendar_config_track_new(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
calendars_config_track: bool,
expected_state: State,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test calendar config that overrides whether or not a calendar is tracked."""
@@ -421,11 +249,11 @@ async def test_calendar_config_track_new(
async def test_add_event(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that adds an event."""
@@ -471,7 +299,6 @@ async def test_add_event(
)
async def test_add_event_date_in_x(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
@@ -479,6 +306,7 @@ async def test_add_event_date_in_x(
date_fields: dict[str, Any],
start_timedelta: datetime.timedelta,
end_timedelta: datetime.timedelta,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that adds an event with various time ranges."""
@@ -514,10 +342,10 @@ async def test_add_event_date_in_x(
async def test_add_event_date(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
mock_insert_event: Mock,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that sets a date range."""
@@ -554,11 +382,11 @@ async def test_add_event_date(
async def test_add_event_date_time(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
mock_insert_event: Mock,
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test service call that adds an event with a date time range."""
@@ -601,10 +429,10 @@ async def test_add_event_date_time(
async def test_scan_calendars(
hass: HomeAssistant,
- mock_token_read: None,
component_setup: ComponentSetup,
mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any],
+ setup_config_entry: MockConfigEntry,
) -> None:
"""Test finding a calendar from the API."""