mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Update google calendar integration with a config flow (#68010)
* Convert google calendar to config flow and async * Call correct exchange method * Fix async method and reduce unnecessary diffs * Wording improvements * Reduce unnecessary diffs * Run load/update config from executor * Update homeassistant/components/google/calendar.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Remove unnecessary updating of unexpected multiple config entries. * Remove unnecessary unique_id checks * Improve readability with comments about device code expiration * Update homeassistant/components/google/calendar.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/google/calendar.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/google/api.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Add comment for when code is none on timeout Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
4988c4683c
commit
7876ffe9e3
@ -1,23 +1,20 @@
|
|||||||
"""Support for Google - Calendar Event Devices."""
|
"""Support for Google - Calendar Event Devices."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from oauth2client.client import (
|
from httplib2.error import ServerNotFoundError
|
||||||
FlowExchangeError,
|
|
||||||
OAuth2DeviceCodeError,
|
|
||||||
OAuth2WebServerFlow,
|
|
||||||
)
|
|
||||||
from oauth2client.file import Storage
|
from oauth2client.file import Storage
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from voluptuous.error import Error as VoluptuousError
|
from voluptuous.error import Error as VoluptuousError
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from homeassistant.components import persistent_notification
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
CONF_CLIENT_SECRET,
|
CONF_CLIENT_SECRET,
|
||||||
@ -25,20 +22,28 @@ from homeassistant.const import (
|
|||||||
CONF_ENTITIES,
|
CONF_ENTITIES,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_OFFSET,
|
CONF_OFFSET,
|
||||||
Platform,
|
|
||||||
)
|
)
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
import homeassistant.helpers.config_validation as cv
|
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.entity import generate_entity_id
|
||||||
from homeassistant.helpers.event import track_utc_time_change
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DOMAIN = "google"
|
|
||||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||||
|
|
||||||
CONF_TRACK_NEW = "track_new_calendar"
|
CONF_TRACK_NEW = "track_new_calendar"
|
||||||
@ -48,7 +53,6 @@ CONF_TRACK = "track"
|
|||||||
CONF_SEARCH = "search"
|
CONF_SEARCH = "search"
|
||||||
CONF_IGNORE_AVAILABILITY = "ignore_availability"
|
CONF_IGNORE_AVAILABILITY = "ignore_availability"
|
||||||
CONF_MAX_RESULTS = "max_results"
|
CONF_MAX_RESULTS = "max_results"
|
||||||
CONF_CALENDAR_ACCESS = "calendar_access"
|
|
||||||
|
|
||||||
DEFAULT_CONF_OFFSET = "!!"
|
DEFAULT_CONF_OFFSET = "!!"
|
||||||
|
|
||||||
@ -74,27 +78,11 @@ SERVICE_SCAN_CALENDARS = "scan_for_calendars"
|
|||||||
SERVICE_FOUND_CALENDARS = "found_calendar"
|
SERVICE_FOUND_CALENDARS = "found_calendar"
|
||||||
SERVICE_ADD_EVENT = "add_event"
|
SERVICE_ADD_EVENT = "add_event"
|
||||||
|
|
||||||
DATA_SERVICE = "service"
|
|
||||||
|
|
||||||
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
|
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
|
||||||
|
|
||||||
TOKEN_FILE = f".{DOMAIN}.token"
|
TOKEN_FILE = f".{DOMAIN}.token"
|
||||||
|
|
||||||
|
PLATFORMS = ["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
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
@ -159,131 +147,79 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def do_authentication(
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
hass: HomeAssistant,
|
"""Set up the Google component."""
|
||||||
hass_config: ConfigType,
|
conf = config.get(DOMAIN, {})
|
||||||
config: ConfigType,
|
hass.data[DOMAIN] = {DATA_CONFIG: conf}
|
||||||
storage: Storage,
|
config_flow.OAuth2FlowHandler.async_register_implementation(
|
||||||
) -> 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}<br />You will need to restart hass after fixing." "",
|
|
||||||
title=NOTIFICATION_TITLE,
|
|
||||||
notification_id=NOTIFICATION_ID,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
persistent_notification.create(
|
|
||||||
hass,
|
hass,
|
||||||
(
|
DeviceAuth(
|
||||||
f"In order to authorize Home-Assistant to view your calendars "
|
|
||||||
f'you must visit: <a href="{dev_flow.verification_url}" target="_blank">{dev_flow.verification_url}</a> 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(
|
|
||||||
hass,
|
hass,
|
||||||
(
|
conf[CONF_CLIENT_ID],
|
||||||
f"We are all setup now. Check {YAML_DEVICES} for calendars that have "
|
conf[CONF_CLIENT_SECRET],
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
# Import credentials from the old token file into the new way as
|
||||||
|
# a ConfigEntry managed by home assistant.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
storage = Storage(hass.config.path(TOKEN_FILE))
|
storage = Storage(hass.config.path(TOKEN_FILE))
|
||||||
hass.data[DOMAIN] = {
|
creds = await hass.async_add_executor_job(storage.get)
|
||||||
DATA_SERVICE: GoogleCalendarService(hass, storage),
|
if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes:
|
||||||
}
|
_LOGGER.debug("Importing configuration entry with credentials")
|
||||||
creds = storage.get()
|
hass.async_create_task(
|
||||||
if (
|
hass.config_entries.flow.async_init(
|
||||||
not creds
|
DOMAIN,
|
||||||
or not creds.scopes
|
context={"source": config_entries.SOURCE_IMPORT},
|
||||||
or conf[CONF_CALENDAR_ACCESS].scope not in creds.scopes
|
data={
|
||||||
):
|
"creds": creds,
|
||||||
do_authentication(hass, config, conf, storage)
|
},
|
||||||
else:
|
)
|
||||||
do_setup(hass, config, conf)
|
)
|
||||||
|
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
|
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: HomeAssistant,
|
||||||
hass_config: ConfigType,
|
|
||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
calendar_service: GoogleCalendarService,
|
calendar_service: GoogleCalendarService,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the service listeners."""
|
"""Set up the service listeners."""
|
||||||
|
|
||||||
created_calendars = set()
|
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:
|
async def _found_calendar(call: ServiceCall) -> None:
|
||||||
"""Check if we know about a calendar and generate PLATFORM_DISCOVER."""
|
|
||||||
calendar = get_calendar_info(hass, call.data)
|
calendar = get_calendar_info(hass, call.data)
|
||||||
calendar_id = calendar[CONF_CAL_ID]
|
calendar_id = calendar[CONF_CAL_ID]
|
||||||
|
|
||||||
@ -294,31 +230,33 @@ def setup_services(
|
|||||||
# Populate the yaml file with all discovered calendars
|
# Populate the yaml file with all discovered calendars
|
||||||
if calendar_id not in calendars:
|
if calendar_id not in calendars:
|
||||||
calendars[calendar_id] = calendar
|
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:
|
else:
|
||||||
# Prefer entity/name information from yaml, overriding api
|
# Prefer entity/name information from yaml, overriding api
|
||||||
calendar = calendars[calendar_id]
|
calendar = calendars[calendar_id]
|
||||||
|
async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar)
|
||||||
|
|
||||||
discovery.load_platform(
|
hass.services.async_register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
|
||||||
hass,
|
|
||||||
Platform.CALENDAR,
|
|
||||||
DOMAIN,
|
|
||||||
calendar,
|
|
||||||
hass_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
|
async def _scan_for_calendars(call: ServiceCall) -> None:
|
||||||
|
|
||||||
def _scan_for_calendars(call: ServiceCall) -> None:
|
|
||||||
"""Scan for new calendars."""
|
"""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:
|
for calendar in calendars:
|
||||||
calendar[CONF_TRACK] = config[CONF_TRACK_NEW]
|
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."""
|
"""Add a new event to calendar."""
|
||||||
start = {}
|
start = {}
|
||||||
end = {}
|
end = {}
|
||||||
@ -354,7 +292,7 @@ def setup_services(
|
|||||||
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
|
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
|
||||||
end = {"dateTime": end_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],
|
call.data[EVENT_CALENDAR_ID],
|
||||||
{
|
{
|
||||||
"summary": call.data[EVENT_SUMMARY],
|
"summary": call.data[EVENT_SUMMARY],
|
||||||
@ -366,20 +304,11 @@ def setup_services(
|
|||||||
|
|
||||||
# Only expose the add event service if we have the correct permissions
|
# Only expose the add event service if we have the correct permissions
|
||||||
if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
|
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
|
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(
|
def get_calendar_info(
|
||||||
hass: HomeAssistant, calendar: Mapping[str, Any]
|
hass: HomeAssistant, calendar: Mapping[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
@ -2,20 +2,166 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from googleapiclient import discovery as google_discovery
|
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 homeassistant.util import dt
|
||||||
|
|
||||||
|
from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
EVENT_PAGE_SIZE = 100
|
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:
|
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:
|
class GoogleCalendarService:
|
||||||
"""Calendar service interface to Google."""
|
"""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."""
|
"""Init the Google Calendar service."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._storage = storage
|
self._session = session
|
||||||
|
|
||||||
def _get_service(self) -> google_discovery.Resource:
|
async def _async_get_service(self) -> google_discovery.Resource:
|
||||||
"""Get the calendar service from the storage file token."""
|
"""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(
|
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."""
|
"""Return the list of calendars the user has added to their list."""
|
||||||
cal_list = self._get_service().calendarList() # pylint: disable=no-member
|
service = await self._async_get_service()
|
||||||
return cal_list.list().execute()["items"]
|
|
||||||
|
|
||||||
def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]:
|
def _list_calendars() -> list[dict[str, Any]]:
|
||||||
"""Create an event."""
|
cal_list = service.calendarList() # pylint: disable=no-member
|
||||||
events = self._get_service().events() # pylint: disable=no-member
|
return cal_list.list().execute()["items"]
|
||||||
return events.insert(calendarId=calendar_id, body=event).execute()
|
|
||||||
|
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(
|
async def async_list_events(
|
||||||
self,
|
self,
|
||||||
@ -56,33 +220,20 @@ class GoogleCalendarService:
|
|||||||
page_token: str | None = None,
|
page_token: str | None = None,
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
"""Return the list of events."""
|
"""Return the list of events."""
|
||||||
return await self._hass.async_add_executor_job(
|
service = await self._async_get_service()
|
||||||
self.list_events,
|
|
||||||
calendar_id,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
search,
|
|
||||||
page_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_events(
|
def _list_events() -> tuple[list[dict[str, Any]], str | None]:
|
||||||
self,
|
events = service.events() # pylint: disable=no-member
|
||||||
calendar_id: str,
|
result = events.list(
|
||||||
start_time: datetime.datetime | None = None,
|
calendarId=calendar_id,
|
||||||
end_time: datetime.datetime | None = None,
|
timeMin=_api_time_format(start_time if start_time else dt.now()),
|
||||||
search: str | None = None,
|
timeMax=_api_time_format(end_time),
|
||||||
page_token: str | None = None,
|
q=search,
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
maxResults=EVENT_PAGE_SIZE,
|
||||||
"""Return the list of events."""
|
pageToken=page_token,
|
||||||
events = self._get_service().events() # pylint: disable=no-member
|
singleEvents=True, # Flattens recurring events
|
||||||
result = events.list(
|
orderBy="startTime",
|
||||||
calendarId=calendar_id,
|
).execute()
|
||||||
timeMin=_api_time_format(start_time if start_time else dt.now()),
|
return (result["items"], result.get("nextPageToken"))
|
||||||
timeMax=_api_time_format(end_time),
|
|
||||||
q=search,
|
return await self._hass.async_add_executor_job(_list_events)
|
||||||
maxResults=EVENT_PAGE_SIZE,
|
|
||||||
pageToken=page_token,
|
|
||||||
singleEvents=True, # Flattens recurring events
|
|
||||||
orderBy="startTime",
|
|
||||||
).execute()
|
|
||||||
return (result["items"], result.get("nextPageToken"))
|
|
||||||
|
@ -14,11 +14,13 @@ from homeassistant.components.calendar import (
|
|||||||
calculate_offset,
|
calculate_offset,
|
||||||
is_offset_reached,
|
is_offset_reached,
|
||||||
)
|
)
|
||||||
|
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
|
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 import generate_entity_id
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
@ -29,8 +31,10 @@ from . import (
|
|||||||
DATA_SERVICE,
|
DATA_SERVICE,
|
||||||
DEFAULT_CONF_OFFSET,
|
DEFAULT_CONF_OFFSET,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
SERVICE_SCAN_CALENDARS,
|
||||||
)
|
)
|
||||||
from .api import GoogleCalendarService
|
from .api import GoogleCalendarService
|
||||||
|
from .const import DISCOVER_CALENDAR
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -48,19 +52,39 @@ TRANSPARENCY = "transparency"
|
|||||||
OPAQUE = "opaque"
|
OPAQUE = "opaque"
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
config: ConfigType,
|
|
||||||
add_entities: AddEntitiesCallback,
|
|
||||||
disc_info: DiscoveryInfoType | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the calendar platform for event devices."""
|
"""Set up the google calendar platform."""
|
||||||
if disc_info is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]):
|
@callback
|
||||||
return
|
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]
|
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
|
||||||
entities = []
|
entities = []
|
||||||
for data in disc_info[CONF_ENTITIES]:
|
for data in disc_info[CONF_ENTITIES]:
|
||||||
@ -74,7 +98,7 @@ def setup_platform(
|
|||||||
)
|
)
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
|
|
||||||
add_entities(entities, True)
|
async_add_entities(entities, True)
|
||||||
|
|
||||||
|
|
||||||
class GoogleCalendarEventDevice(CalendarEventDevice):
|
class GoogleCalendarEventDevice(CalendarEventDevice):
|
||||||
@ -144,10 +168,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
|||||||
return event_list
|
return event_list
|
||||||
|
|
||||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||||
def update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Get the latest data."""
|
"""Get the latest data."""
|
||||||
try:
|
try:
|
||||||
items, _ = self._calendar_service.list_events(
|
items, _ = await self._calendar_service.async_list_events(
|
||||||
self._calendar_id, search=self._search
|
self._calendar_id, search=self._search
|
||||||
)
|
)
|
||||||
except ServerNotFoundError as err:
|
except ServerNotFoundError as err:
|
||||||
|
128
homeassistant/components/google/config_flow.py
Normal file
128
homeassistant/components/google/config_flow.py
Normal file
@ -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()
|
30
homeassistant/components/google/const.py
Normal file
30
homeassistant/components/google/const.py
Normal file
@ -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
|
@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"domain": "google",
|
"domain": "google",
|
||||||
"name": "Google Calendars",
|
"name": "Google Calendars",
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["http"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
|
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"google-api-python-client==2.38.0",
|
"google-api-python-client==2.38.0",
|
||||||
|
31
homeassistant/components/google/strings.json
Normal file
31
homeassistant/components/google/strings.json
Normal file
@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
31
homeassistant/components/google/translations/en.json
Normal file
31
homeassistant/components/google/translations/en.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -125,6 +125,7 @@ FLOWS = [
|
|||||||
"goalzero",
|
"goalzero",
|
||||||
"gogogate2",
|
"gogogate2",
|
||||||
"goodwe",
|
"goodwe",
|
||||||
|
"google",
|
||||||
"google_travel_time",
|
"google_travel_time",
|
||||||
"gpslogger",
|
"gpslogger",
|
||||||
"gree",
|
"gree",
|
||||||
|
@ -18,6 +18,8 @@ from homeassistant.setup import async_setup_component
|
|||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
|
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
|
||||||
|
|
||||||
ApiResult = Callable[[dict[str, Any]], None]
|
ApiResult = Callable[[dict[str, Any]], None]
|
||||||
@ -156,6 +158,25 @@ async def storage() -> YieldFixture[FakeStorage]:
|
|||||||
yield storage
|
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
|
@pytest.fixture
|
||||||
async def mock_token_read(
|
async def mock_token_read(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -53,10 +53,15 @@ TEST_EVENT = {
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_test_setup(
|
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."""
|
"""Fixture that pulls in the default fixtures for tests in this file."""
|
||||||
mock_calendars_list({"items": [test_api_calendar]})
|
mock_calendars_list({"items": [test_api_calendar]})
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@ -300,12 +305,11 @@ 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 beyond update/throttle point
|
||||||
now += datetime.timedelta(minutes=30)
|
now += datetime.timedelta(minutes=30)
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.google.api.google_discovery.build"
|
"homeassistant.components.google.api.google_discovery.build"
|
||||||
) as mock, patch("homeassistant.util.utcnow", return_value=now):
|
) as mock, patch("homeassistant.util.utcnow", return_value=now):
|
||||||
|
|
||||||
mock.return_value.events.return_value.list.return_value.execute.return_value = {
|
mock.return_value.events.return_value.list.return_value.execute.return_value = {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
@ -417,3 +421,19 @@ async def test_opaque_event(
|
|||||||
assert response.status == HTTPStatus.OK
|
assert response.status == HTTPStatus.OK
|
||||||
events = await response.json()
|
events = await response.json()
|
||||||
assert (len(events) > 0) == expect_visible_event
|
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)
|
||||||
|
350
tests/components/google/test_config_flow.py
Normal file
350
tests/components/google/test_config_flow.py
Normal file
@ -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
|
@ -6,11 +6,6 @@ import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import Mock, call, patch
|
from unittest.mock import Mock, call, patch
|
||||||
|
|
||||||
from oauth2client.client import (
|
|
||||||
FlowExchangeError,
|
|
||||||
OAuth2Credentials,
|
|
||||||
OAuth2DeviceCodeError,
|
|
||||||
)
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.google import (
|
from homeassistant.components.google import (
|
||||||
@ -18,6 +13,7 @@ from homeassistant.components.google import (
|
|||||||
SERVICE_ADD_EVENT,
|
SERVICE_ADD_EVENT,
|
||||||
SERVICE_SCAN_CALENDARS,
|
SERVICE_SCAN_CALENDARS,
|
||||||
)
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.const import STATE_OFF
|
from homeassistant.const import STATE_OFF
|
||||||
from homeassistant.core import HomeAssistant, State
|
from homeassistant.core import HomeAssistant, State
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
@ -30,73 +26,13 @@ from .conftest import (
|
|||||||
TEST_YAML_ENTITY_NAME,
|
TEST_YAML_ENTITY_NAME,
|
||||||
ApiResult,
|
ApiResult,
|
||||||
ComponentSetup,
|
ComponentSetup,
|
||||||
YieldFixture,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import async_fire_time_changed
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
# Typing helpers
|
# Typing helpers
|
||||||
HassApi = Callable[[], Awaitable[dict[str, Any]]]
|
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:
|
def assert_state(actual: State | None, expected: State | None) -> None:
|
||||||
"""Assert that the two states are equal."""
|
"""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
|
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,
|
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,
|
component_setup: ComponentSetup,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test successful creds setup."""
|
"""Test load and unload of a ConfigEntry."""
|
||||||
mock_calendars_list({"items": [test_api_calendar]})
|
await component_setup()
|
||||||
assert await component_setup()
|
|
||||||
|
|
||||||
# Run one tick to invoke the credential exchange check
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
now = utcnow()
|
assert len(entries) == 1
|
||||||
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
|
entry = entries[0]
|
||||||
|
assert entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
state = hass.states.get(TEST_YAML_ENTITY)
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
assert state
|
assert entry.state == ConfigEntryState.NOT_LOADED
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -228,80 +75,61 @@ async def test_existing_token(
|
|||||||
async def test_existing_token_missing_scope(
|
async def test_existing_token_missing_scope(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
token_scopes: list[str],
|
token_scopes: list[str],
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_yaml: None,
|
config_entry: MockConfigEntry,
|
||||||
mock_calendars_list: ApiResult,
|
|
||||||
test_api_calendar: dict[str, Any],
|
|
||||||
mock_notification: Mock,
|
|
||||||
mock_code_flow: Mock,
|
|
||||||
mock_exchange: Mock,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setup where existing token does not have sufficient scopes."""
|
"""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()
|
assert await component_setup()
|
||||||
|
|
||||||
# Run one tick to invoke the credential exchange check
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
now = utcnow()
|
assert len(entries) == 1
|
||||||
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
|
assert entries[0].state is ConfigEntryState.SETUP_ERROR
|
||||||
assert len(mock_exchange.mock_calls) == 1
|
|
||||||
|
|
||||||
state = hass.states.get(TEST_YAML_ENTITY)
|
flows = hass.config_entries.flow.async_progress()
|
||||||
assert state
|
assert len(flows) == 1
|
||||||
assert state.name == TEST_YAML_ENTITY_NAME
|
assert flows[0]["step_id"] == "reauth_confirm"
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
|
@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
|
||||||
async def test_calendar_yaml_missing_required_fields(
|
async def test_calendar_yaml_missing_required_fields(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
calendars_config: list[dict[str, Any]],
|
calendars_config: list[dict[str, Any]],
|
||||||
mock_calendars_yaml: None,
|
mock_calendars_yaml: None,
|
||||||
mock_notification: Mock,
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setup with a missing schema fields, ignores the error and continues."""
|
"""Test setup with a missing schema fields, ignores the error and continues."""
|
||||||
assert await component_setup()
|
assert await component_setup()
|
||||||
|
|
||||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||||
|
|
||||||
mock_notification.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
|
@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
|
||||||
async def test_invalid_calendar_yaml(
|
async def test_invalid_calendar_yaml(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
calendars_config: list[dict[str, Any]],
|
calendars_config: list[dict[str, Any]],
|
||||||
mock_calendars_yaml: None,
|
mock_calendars_yaml: None,
|
||||||
mock_notification: Mock,
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> 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
|
# 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)
|
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||||
|
|
||||||
mock_notification.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_calendar_yaml_error(
|
async def test_calendar_yaml_error(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
mock_notification: Mock,
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test setup with yaml file not found."""
|
"""Test setup with yaml file not found."""
|
||||||
|
|
||||||
mock_calendars_list({"items": [test_api_calendar]})
|
mock_calendars_list({"items": [test_api_calendar]})
|
||||||
|
|
||||||
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
|
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
|
||||||
@ -344,12 +172,12 @@ async def test_calendar_yaml_error(
|
|||||||
)
|
)
|
||||||
async def test_track_new(
|
async def test_track_new(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
mock_calendars_yaml: None,
|
mock_calendars_yaml: None,
|
||||||
expected_state: State,
|
expected_state: State,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test behavior of configuration.yaml settings for tracking new calendars not in the config."""
|
"""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", [[]])
|
@pytest.mark.parametrize("calendars_config", [[]])
|
||||||
async def test_found_calendar_from_api(
|
async def test_found_calendar_from_api(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_yaml: None,
|
mock_calendars_yaml: None,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test finding a calendar from the API."""
|
"""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(
|
async def test_calendar_config_track_new(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_yaml: None,
|
mock_calendars_yaml: None,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
calendars_config_track: bool,
|
calendars_config_track: bool,
|
||||||
expected_state: State,
|
expected_state: State,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test calendar config that overrides whether or not a calendar is tracked."""
|
"""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(
|
async def test_add_event(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
mock_insert_event: Mock,
|
mock_insert_event: Mock,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test service call that adds an event."""
|
"""Test service call that adds an event."""
|
||||||
|
|
||||||
@ -471,7 +299,6 @@ async def test_add_event(
|
|||||||
)
|
)
|
||||||
async def test_add_event_date_in_x(
|
async def test_add_event_date_in_x(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
@ -479,6 +306,7 @@ async def test_add_event_date_in_x(
|
|||||||
date_fields: dict[str, Any],
|
date_fields: dict[str, Any],
|
||||||
start_timedelta: datetime.timedelta,
|
start_timedelta: datetime.timedelta,
|
||||||
end_timedelta: datetime.timedelta,
|
end_timedelta: datetime.timedelta,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test service call that adds an event with various time ranges."""
|
"""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(
|
async def test_add_event_date(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
mock_insert_event: Mock,
|
mock_insert_event: Mock,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test service call that sets a date range."""
|
"""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(
|
async def test_add_event_date_time(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
mock_insert_event: Mock,
|
mock_insert_event: Mock,
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test service call that adds an event with a date time range."""
|
"""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(
|
async def test_scan_calendars(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_token_read: None,
|
|
||||||
component_setup: ComponentSetup,
|
component_setup: ComponentSetup,
|
||||||
mock_calendars_list: ApiResult,
|
mock_calendars_list: ApiResult,
|
||||||
test_api_calendar: dict[str, Any],
|
test_api_calendar: dict[str, Any],
|
||||||
|
setup_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test finding a calendar from the API."""
|
"""Test finding a calendar from the API."""
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user