Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Google Drive integration for backup #134576

Open
wants to merge 23 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ homeassistant.components.goalzero.*
homeassistant.components.google.*
homeassistant.components.google_assistant_sdk.*
homeassistant.components.google_cloud.*
homeassistant.components.google_drive.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.govee_ble.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions homeassistant/brands/google.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"google_assistant",
"google_assistant_sdk",
"google_cloud",
"google_drive",
"google_generative_ai_conversation",
"google_mail",
"google_maps",
Expand Down
65 changes: 65 additions & 0 deletions homeassistant/components/google_drive/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""The Google Drive integration."""

from __future__ import annotations

from collections.abc import Callable

from google_drive_api.exceptions import GoogleDriveApiError

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.util.hass_dict import HassKey

from .api import AsyncConfigEntryAuth, DriveClient
from .const import DOMAIN

DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)


type GoogleDriveConfigEntry = ConfigEntry[DriveClient]


async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool:
"""Set up Google Drive from a config entry."""
auth = AsyncConfigEntryAuth(
async_get_clientsession(hass),
OAuth2Session(
hass, entry, await async_get_config_entry_implementation(hass, entry)
),
)

# Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
await auth.async_get_access_token()

client = DriveClient(await instance_id.async_get(hass), auth)
entry.runtime_data = client

# Test we can access Google Drive and raise if not
try:
await client.async_create_ha_root_folder_if_not_exists()
except GoogleDriveApiError as err:
raise ConfigEntryNotReady from err

return True


async def async_unload_entry(
hass: HomeAssistant, entry: GoogleDriveConfigEntry
) -> bool:
"""Unload a config entry."""
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
return True


async def _notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
202 changes: 202 additions & 0 deletions homeassistant/components/google_drive/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""API for Google Drive bound to Home Assistant OAuth."""

from __future__ import annotations

from collections.abc import AsyncIterator, Callable, Coroutine
import json
import logging
from typing import Any

from aiohttp import ClientSession, ClientTimeout, StreamReader
from aiohttp.client_exceptions import ClientError, ClientResponseError
from google.auth.exceptions import RefreshError
from google_drive_api.api import AbstractAuth, GoogleDriveApi

from homeassistant.components.backup import AgentBackup
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import config_entry_oauth2_flow

_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600

_LOGGER = logging.getLogger(__name__)


class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Google Drive authentication tied to an OAuth2 based config entry."""

def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize AsyncConfigEntryAuth."""
super().__init__(websession)
self._oauth_session = oauth_session

async def async_get_access_token(self) -> str:
"""Return a valid access token."""
try:
await self._oauth_session.async_ensure_token_valid()
except (RefreshError, ClientResponseError, ClientError) as ex:
if (
self._oauth_session.config_entry.state
is ConfigEntryState.SETUP_IN_PROGRESS
):
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from ex
raise ConfigEntryNotReady from ex
if (

Check warning on line 56 in homeassistant/components/google_drive/api.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/google_drive/api.py#L56

Added line #L56 was not covered by tests
isinstance(ex, RefreshError)
or hasattr(ex, "status")
and ex.status == 400
):
self._oauth_session.config_entry.async_start_reauth(

Check warning on line 61 in homeassistant/components/google_drive/api.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/google_drive/api.py#L61

Added line #L61 was not covered by tests
self._oauth_session.hass
)
raise HomeAssistantError(ex) from ex

Check warning on line 64 in homeassistant/components/google_drive/api.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/google_drive/api.py#L64

Added line #L64 was not covered by tests
return str(self._oauth_session.token[CONF_ACCESS_TOKEN])


class AsyncConfigFlowAuth(AbstractAuth):
"""Provide authentication tied to a fixed token for the config flow."""

def __init__(
self,
websession: ClientSession,
token: str,
) -> None:
"""Initialize AsyncConfigFlowAuth."""
super().__init__(websession)
self._token = token

async def async_get_access_token(self) -> str:
"""Return a valid access token."""
return self._token

Check warning on line 82 in homeassistant/components/google_drive/api.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/google_drive/api.py#L82

Added line #L82 was not covered by tests


class DriveClient:
"""Google Drive client."""

def __init__(
self,
ha_instance_id: str,
auth: AbstractAuth,
) -> None:
"""Initialize Google Drive client."""
self._ha_instance_id = ha_instance_id
self._api = GoogleDriveApi(auth)

async def async_get_email_address(self) -> str:
"""Get email address of the current user."""
res = await self._api.get_user(params={"fields": "user(emailAddress)"})
return str(res["user"]["emailAddress"])

async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]:
"""Create Home Assistant folder if it doesn't exist."""
fields = "id,name"
query = " and ".join(
[
"properties has { key='ha' and value='root' }",
f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}",
"trashed=false",
]
)
res = await self._api.list_files(
params={"q": query, "fields": f"files({fields})"}
)
for file in res["files"]:
_LOGGER.debug("Found existing folder: %s", file)
return str(file["id"]), str(file["name"])

file_metadata = {
"name": "Home Assistant",
"mimeType": "application/vnd.google-apps.folder",
"properties": {
"ha": "root",
"instance_id": self._ha_instance_id,
},
}
_LOGGER.debug("Creating new folder with metadata: %s", file_metadata)
res = await self._api.create_file(params={"fields": fields}, json=file_metadata)
_LOGGER.debug("Created folder: %s", res)
return str(res["id"]), str(res["name"])

async def async_upload_backup(
self,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
) -> None:
"""Upload a backup."""
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
backup_metadata = {
"name": f"{backup.name} {backup.date}.tar",
"description": json.dumps(backup.as_dict()),
"parents": [folder_id],
"properties": {
"ha": "backup",
"instance_id": self._ha_instance_id,
"backup_id": backup.backup_id,
},
}
_LOGGER.debug(
"Uploading backup: %s with Google Drive metadata: %s",
backup.backup_id,
backup_metadata,
)
await self._api.upload_file(backup_metadata, open_stream)
_LOGGER.debug(

Check warning on line 155 in homeassistant/components/google_drive/api.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/google_drive/api.py#L155

Added line #L155 was not covered by tests
"Uploaded backup: %s to: '%s'",
backup.backup_id,
backup_metadata["name"],
)

async def async_list_backups(self) -> list[AgentBackup]:
"""List backups."""
query = " and ".join(
[
"properties has { key='ha' and value='backup' }",
f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}",
"trashed=false",
]
)
res = await self._api.list_files(
params={"q": query, "fields": "files(description)"}
)
backups = []
for file in res["files"]:
backup = AgentBackup.from_dict(json.loads(file["description"]))
backups.append(backup)
return backups

async def async_get_backup_file_id(self, backup_id: str) -> str | None:
"""Get file_id of backup if it exists."""
query = " and ".join(
[
"properties has { key='ha' and value='backup' }",
f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}",
f"properties has {{ key='backup_id' and value='{backup_id}' }}",
]
)
res = await self._api.list_files(params={"q": query, "fields": "files(id)"})
for file in res["files"]:
return str(file["id"])
return None

async def async_delete(self, file_id: str) -> None:
"""Delete file."""
await self._api.delete_file(file_id)

async def async_download(self, file_id: str) -> StreamReader:
"""Download a file."""
resp = await self._api.get_file_content(
file_id, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT)
)
return resp.content
21 changes: 21 additions & 0 deletions homeassistant/components/google_drive/application_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""application_credentials platform for Google Drive."""

from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant


async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
"https://accounts.google.com/o/oauth2/v2/auth",
"https://oauth2.googleapis.com/token",
)


async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {

Check warning on line 17 in homeassistant/components/google_drive/application_credentials.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/google_drive/application_credentials.py#L17

Added line #L17 was not covered by tests
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
}
Loading