diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index d5252bd01eab9..d3fe021a5a296 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -6,7 +6,7 @@ from google_drive_api.exceptions import GoogleDriveApiError -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id @@ -19,13 +19,13 @@ from .api import AsyncConfigEntryAuth, DriveClient from .const import DOMAIN +from .coordinator import GoogleDriveConfigEntry, GoogleDriveDataUpdateCoordinator DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( f"{DOMAIN}.backup_agent_listeners" ) - -type GoogleDriveConfigEntry = ConfigEntry[DriveClient] +_PLATFORMS = (Platform.SENSOR,) async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool: @@ -41,11 +41,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) 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() + folder_id, _ = await client.async_create_ha_root_folder_if_not_exists() except GoogleDriveApiError as err: raise ConfigEntryNotReady from err @@ -55,6 +54,13 @@ def async_notify_backup_listeners() -> None: entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + entry.runtime_data = GoogleDriveDataUpdateCoordinator( + hass, entry=entry, client=client, backup_folder_id=folder_id + ) + await entry.runtime_data.async_config_entry_first_refresh() + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True @@ -62,4 +68,6 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + return True diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index 2a96b5e09a07f..035c19717b82f 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine +from dataclasses import dataclass import json import logging from typing import Any @@ -27,6 +28,16 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class StorageQuotaData: + """Class to represent storage quota data.""" + + limit: int | None + usage: int + usage_in_drive: int + usage_in_trash: int + + class AsyncConfigEntryAuth(AbstractAuth): """Provide Google Drive authentication tied to an OAuth2 based config entry.""" @@ -95,6 +106,19 @@ async def async_get_email_address(self) -> str: res = await self._api.get_user(params={"fields": "user(emailAddress)"}) return str(res["user"]["emailAddress"]) + async def async_get_storage_quota(self) -> StorageQuotaData: + """Get storage quota of the current user.""" + res = await self._api.get_user(params={"fields": "storageQuota"}) + + storageQuota = res["storageQuota"] + limit = storageQuota.get("limit") + return StorageQuotaData( + limit=int(limit) if limit is not None else None, + usage=int(storageQuota.get("usage", 0)), + usage_in_drive=int(storageQuota.get("usageInDrive", 0)), + usage_in_trash=int(storageQuota.get("usageInTrash", 0)), + ) + 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" @@ -178,6 +202,12 @@ async def async_list_backups(self) -> list[AgentBackup]: backups.append(backup) return backups + async def async_get_size_of_all_backups(self) -> int: + """Get size of all backups.""" + backups = await self.async_list_backups() + + return sum(backup.size for backup in 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( diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index a4b7fc956ce93..bc306fe61d71b 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -68,7 +68,7 @@ def __init__(self, config_entry: GoogleDriveConfigEntry) -> None: assert config_entry.unique_id self.name = config_entry.title self.unique_id = slugify(config_entry.unique_id) - self._client = config_entry.runtime_data + self._client = config_entry.runtime_data.client async def async_upload_backup( self, diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py index fb74af422108e..cfcff47f658a2 100644 --- a/homeassistant/components/google_drive/config_flow.py +++ b/homeassistant/components/google_drive/config_flow.py @@ -14,10 +14,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import AsyncConfigFlowAuth, DriveClient -from .const import DOMAIN +from .const import DOMAIN, DRIVE_FOLDER_URL_PREFIX DEFAULT_NAME = "Google Drive" -DRIVE_FOLDER_URL_PREFIX = "https://drive.google.com/drive/folders/" OAUTH2_SCOPES = [ "https://www.googleapis.com/auth/drive.file", ] diff --git a/homeassistant/components/google_drive/const.py b/homeassistant/components/google_drive/const.py index 3f0b3e9d6100a..f446b38e61a6f 100644 --- a/homeassistant/components/google_drive/const.py +++ b/homeassistant/components/google_drive/const.py @@ -2,4 +2,9 @@ from __future__ import annotations +from datetime import timedelta + DOMAIN = "google_drive" + +SCAN_INTERVAL = timedelta(hours=6) +DRIVE_FOLDER_URL_PREFIX = "https://drive.google.com/drive/folders/" diff --git a/homeassistant/components/google_drive/coordinator.py b/homeassistant/components/google_drive/coordinator.py new file mode 100644 index 0000000000000..c6f613ab76365 --- /dev/null +++ b/homeassistant/components/google_drive/coordinator.py @@ -0,0 +1,76 @@ +"""DataUpdateCoordinator for Google Drive.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import DriveClient, StorageQuotaData +from .const import DOMAIN, SCAN_INTERVAL + +type GoogleDriveConfigEntry = ConfigEntry[GoogleDriveDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SensorData: + """Class to represent sensor data.""" + + storage_quota: StorageQuotaData + all_backups_size: int + + +class GoogleDriveDataUpdateCoordinator(DataUpdateCoordinator[SensorData]): + """Class to manage fetching Google Drive data from single endpoint.""" + + client: DriveClient + config_entry: GoogleDriveConfigEntry + email_address: str + backup_folder_id: str + + def __init__( + self, + hass: HomeAssistant, + *, + client: DriveClient, + backup_folder_id: str, + entry: GoogleDriveConfigEntry, + ) -> None: + """Initialize Google Drive data updater.""" + self.client = client + self.backup_folder_id = backup_folder_id + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + """Do initialization logic.""" + self.email_address = await self.client.async_get_email_address() + + async def _async_update_data(self) -> SensorData: + """Fetch data from Google Drive.""" + try: + storage_quota = await self.client.async_get_storage_quota() + all_backups_size = await self.client.async_get_size_of_all_backups() + return SensorData( + storage_quota=storage_quota, + all_backups_size=all_backups_size, + ) + except GoogleDriveApiError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_response_google_drive_error", + translation_placeholders={"error": str(error)}, + ) from error diff --git a/homeassistant/components/google_drive/diagnostics.py b/homeassistant/components/google_drive/diagnostics.py new file mode 100644 index 0000000000000..494ec52346f8a --- /dev/null +++ b/homeassistant/components/google_drive/diagnostics.py @@ -0,0 +1,48 @@ +"""Diagnostics support for Google Drive.""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components.backup import ( + DATA_MANAGER as BACKUP_DATA_MANAGER, + BackupManager, +) +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import GoogleDriveConfigEntry + +TO_REDACT = (CONF_ACCESS_TOKEN, "refresh_token") + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: GoogleDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data + backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER] + + backups = await coordinator.client.async_list_backups() + + data = { + "coordinator_data": dataclasses.asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + "backup_folder_id": coordinator.backup_folder_id, + "backup_agents": [ + {"name": agent.name} + for agent in backup_manager.backup_agents.values() + if agent.domain == DOMAIN + ], + "backup": [backup.as_dict() for backup in backups], + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/google_drive/entity.py b/homeassistant/components/google_drive/entity.py new file mode 100644 index 0000000000000..5fdb6f5724bb4 --- /dev/null +++ b/homeassistant/components/google_drive/entity.py @@ -0,0 +1,25 @@ +"""Define the Google Drive entity.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, DRIVE_FOLDER_URL_PREFIX +from .coordinator import GoogleDriveDataUpdateCoordinator + + +class GoogleDriveEntity(CoordinatorEntity[GoogleDriveDataUpdateCoordinator]): + """Defines a base Google Drive entity.""" + + _attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Google Drive device.""" + return DeviceInfo( + identifiers={(DOMAIN, str(self.coordinator.config_entry.unique_id))}, + name=self.coordinator.email_address, + manufacturer="Google", + model="Google Drive", + configuration_url=f"{DRIVE_FOLDER_URL_PREFIX}{self.coordinator.backup_folder_id}", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/google_drive/icons.json b/homeassistant/components/google_drive/icons.json new file mode 100644 index 0000000000000..6a4fb75106dd9 --- /dev/null +++ b/homeassistant/components/google_drive/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "backups_size": { + "default": "mdi:database" + }, + "storage_total": { + "default": "mdi:database" + }, + "storage_used": { + "default": "mdi:database" + }, + "storage_used_in_drive": { + "default": "mdi:database" + }, + "storage_used_in_drive_trash": { + "default": "mdi:database" + } + } + } +} diff --git a/homeassistant/components/google_drive/quality_scale.yaml b/homeassistant/components/google_drive/quality_scale.yaml index 70627a6a6d7d6..b4fb1bcf42fce 100644 --- a/homeassistant/components/google_drive/quality_scale.yaml +++ b/homeassistant/components/google_drive/quality_scale.yaml @@ -3,9 +3,7 @@ rules: action-setup: status: exempt comment: No actions. - appropriate-polling: - status: exempt - comment: No polling. + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done @@ -17,12 +15,8 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: No entities. - entity-unique-id: - status: exempt - comment: No entities. + entity-event-setup: done + entity-unique-id: done has-entity-name: status: exempt comment: No entities. @@ -38,39 +32,24 @@ rules: status: exempt comment: No configuration options. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: No entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: No entities. - parallel-updates: - status: exempt - comment: No actions and no entities. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold - devices: - status: exempt - comment: No devices. - diagnostics: - status: exempt - comment: No data to diagnose. + devices: done + diagnostics: done discovery-update-info: status: exempt comment: No discovery. discovery: status: exempt comment: No discovery. - docs-data-update: - status: exempt - comment: No updates. - docs-examples: - status: exempt - comment: | - This integration only serves backup. + docs-data-update: done + docs-examples: done docs-known-limitations: done docs-supported-devices: status: exempt @@ -79,20 +58,13 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: - status: exempt - comment: No devices. - entity-category: - status: exempt - comment: No entities. - entity-device-class: - status: exempt - comment: No entities. - entity-disabled-by-default: - status: exempt - comment: No entities. - entity-translations: - status: exempt - comment: No entities. + status: done + comment: | + This integration has a fixed single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done icon-translations: status: exempt @@ -104,8 +76,9 @@ rules: status: exempt comment: No repairs. stale-devices: - status: exempt - comment: No devices. + status: done + comment: | + This integration has a fixed single service. # Platinum async-dependency: done diff --git a/homeassistant/components/google_drive/sensor.py b/homeassistant/components/google_drive/sensor.py new file mode 100644 index 0000000000000..66137046fb1ec --- /dev/null +++ b/homeassistant/components/google_drive/sensor.py @@ -0,0 +1,127 @@ +"""Support for GoogleDrive sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import ( + GoogleDriveConfigEntry, + GoogleDriveDataUpdateCoordinator, + SensorData, +) +from .entity import GoogleDriveEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class GoogleDriveSensorEntityDescription(SensorEntityDescription): + """Describes GoogleDrive sensor entity.""" + + exists_fn: Callable[[SensorData], bool] = lambda _: True + value_fn: Callable[[SensorData], StateType] + + +SENSORS: tuple[GoogleDriveSensorEntityDescription, ...] = ( + GoogleDriveSensorEntityDescription( + key="storage_total", + translation_key="storage_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.storage_quota.limit, + exists_fn=lambda data: data.storage_quota.limit is not None, + ), + GoogleDriveSensorEntityDescription( + key="storage_used", + translation_key="storage_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.storage_quota.usage, + ), + GoogleDriveSensorEntityDescription( + key="storage_used_in_drive", + translation_key="storage_used_in_drive", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.storage_quota.usage_in_drive, + entity_registry_enabled_default=False, + ), + GoogleDriveSensorEntityDescription( + key="storage_used_in_drive_trash", + translation_key="storage_used_in_drive_trash", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.storage_quota.usage_in_trash, + entity_registry_enabled_default=False, + ), + GoogleDriveSensorEntityDescription( + key="backups_size", + translation_key="backups_size", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.all_backups_size, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GoogleDriveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up GoogleDrive sensor based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + GoogleDriveSensorEntity(coordinator, description) + for description in SENSORS + if description.exists_fn(coordinator.data) + ) + + +class GoogleDriveSensorEntity(GoogleDriveEntity, SensorEntity): + """Defines a Google Drive sensor entity.""" + + entity_description: GoogleDriveSensorEntityDescription + + def __init__( + self, + coordinator: GoogleDriveDataUpdateCoordinator, + description: GoogleDriveSensorEntityDescription, + ) -> None: + """Initialize a Google Drive sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 5eb316dd75896..82a68eee4893a 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -42,5 +42,24 @@ "title": "[%key:common::config_flow::title::reauth%]" } } + }, + "entity": { + "sensor": { + "backups_size": { + "name": "Total size of backups" + }, + "storage_total": { + "name": "Total available storage" + }, + "storage_used": { + "name": "Used storage" + }, + "storage_used_in_drive": { + "name": "Used storage in Drive" + }, + "storage_used_in_drive_trash": { + "name": "Used storage in Drive Trash" + } + } } } diff --git a/tests/components/google_drive/conftest.py b/tests/components/google_drive/conftest.py index 479412ddbe247..9e99e0fdd3a41 100644 --- a/tests/components/google_drive/conftest.py +++ b/tests/components/google_drive/conftest.py @@ -10,9 +10,12 @@ ClientCredential, async_import_client_credential, ) +from homeassistant.components.backup import AddonInfo, AgentBackup from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.unit_conversion import InformationConverter from tests.common import MockConfigEntry @@ -42,6 +45,40 @@ def mock_api() -> Generator[MagicMock]: "homeassistant.components.google_drive.api.GoogleDriveApi" ) as mock_api_cl: mock_api = mock_api_cl.return_value + + def mock_get_user(params=None): + params = params or {} + fields = params.get("fields") + result = {} + if not fields or "storageQuota" in fields: + result["storageQuota"] = { + "limit": InformationConverter.convert( + 10, UnitOfInformation.GIBIBYTES, UnitOfInformation.BYTES + ), + "usage": InformationConverter.convert( + 5, UnitOfInformation.GIBIBYTES, UnitOfInformation.BYTES + ), + "usageInDrive": InformationConverter.convert( + 2, UnitOfInformation.GIBIBYTES, UnitOfInformation.BYTES + ), + "usageInTrash": InformationConverter.convert( + 1, UnitOfInformation.GIBIBYTES, UnitOfInformation.BYTES + ), + } + if not fields or "user(emailAddress)" in fields: + result["user"] = {"emailAddress": TEST_USER_EMAIL} + + return result + + mock_api.get_user = AsyncMock(side_effect=mock_get_user) + # Setup looks up existing folder to make sure it still exists + # and list backups during coordinator update + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"id": "HA folder ID", "name": "HA folder name"}]}, + {"files": []}, + ] + ) yield mock_api @@ -78,3 +115,25 @@ def mock_config_entry(expires_at: int) -> MockConfigEntry: }, }, ) + + +@pytest.fixture +def mock_agent_backup() -> AgentBackup: + """Return a mocked AgentBackup.""" + return AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="test-backup", + database_included=True, + date="2025-01-01T01:23:45.678Z", + extra_metadata={ + "with_automatic_settings": False, + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=InformationConverter.convert( + 100, UnitOfInformation.MEBIBYTES, UnitOfInformation.BYTES + ), + ) diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 55791e385f8b7..45ee041fa7800 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -12,6 +12,37 @@ }), }), ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'storageQuota', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), tuple( 'list_files', tuple( @@ -46,6 +77,37 @@ }), }), ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'storageQuota', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), tuple( 'list_files', tuple( @@ -98,6 +160,37 @@ }), }), ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'storageQuota', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), tuple( 'list_files', tuple( @@ -124,6 +217,37 @@ }), }), ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'storageQuota', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), tuple( 'list_files', tuple( @@ -179,6 +303,37 @@ }), }), ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'storageQuota', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), tuple( 'list_files', tuple( diff --git a/tests/components/google_drive/snapshots/test_diagnostic.ambr b/tests/components/google_drive/snapshots/test_diagnostic.ambr new file mode 100644 index 0000000000000..d88e3da02bd0e --- /dev/null +++ b/tests/components/google_drive/snapshots/test_diagnostic.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'backup': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'backup_id': 'test-backup', + 'database_included': True, + 'date': '2025-01-01T01:23:45.678Z', + 'extra_metadata': dict({ + 'with_automatic_settings': False, + }), + 'folders': list([ + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 104857600.0, + }), + ]), + 'backup_agents': list([ + dict({ + 'name': 'Google Drive entry title', + }), + ]), + 'backup_folder_id': 'HA folder ID', + 'config': dict({ + 'auth_implementation': 'google_drive', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_at': 1636047419.0, + 'refresh_token': '**REDACTED**', + 'scope': 'https://www.googleapis.com/auth/drive.file', + }), + }), + 'coordinator_data': dict({ + 'all_backups_size': 104857600.0, + 'storage_quota': dict({ + 'limit': 10737418240, + 'usage': 5368709120, + 'usage_in_drive': 2147483648, + 'usage_in_trash': 1073741824, + }), + }), + }) +# --- diff --git a/tests/components/google_drive/snapshots/test_sensor.ambr b/tests/components/google_drive/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..f3f53bb0ee48e --- /dev/null +++ b/tests/components/google_drive/snapshots/test_sensor.ambr @@ -0,0 +1,312 @@ +# serializer version: 1 +# name: test_sensor.10 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://drive.google.com/drive/folders/HA folder ID', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_drive', + 'testuser@domain.com', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'Google Drive', + 'model_id': None, + 'name': 'testuser@domain.com', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_total_available_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.testuser_domain_com_total_available_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total available storage', + 'platform': 'google_drive', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_total', + 'unique_id': 'testuser@domain.com_storage_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_total_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'testuser@domain.com Total available storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testuser_domain_com_total_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_total_size_of_backups-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.testuser_domain_com_total_size_of_backups', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total size of backups', + 'platform': 'google_drive', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backups_size', + 'unique_id': 'testuser@domain.com_backups_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_total_size_of_backups-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'testuser@domain.com Total size of backups', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testuser_domain_com_total_size_of_backups', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_used_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.testuser_domain_com_used_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage', + 'platform': 'google_drive', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_used', + 'unique_id': 'testuser@domain.com_storage_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_used_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'testuser@domain.com Used storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testuser_domain_com_used_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_used_storage_in_drive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.testuser_domain_com_used_storage_in_drive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage in Drive', + 'platform': 'google_drive', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_used_in_drive', + 'unique_id': 'testuser@domain.com_storage_used_in_drive', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_used_storage_in_drive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'testuser@domain.com Used storage in Drive', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testuser_domain_com_used_storage_in_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_used_storage_in_drive_trash-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.testuser_domain_com_used_storage_in_drive_trash', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage in Drive Trash', + 'platform': 'google_drive', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_used_in_drive_trash', + 'unique_id': 'testuser@domain.com_storage_used_in_drive_trash', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.testuser_domain_com_used_storage_in_drive_trash-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'testuser@domain.com Used storage in Drive Trash', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testuser_domain_com_used_storage_in_drive_trash', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 6307a7586d2a5..b731be0c34e5c 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -68,11 +68,11 @@ async def setup_integration( """Set up Google Drive integration.""" config_entry.add_to_hass(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() mock_api.list_files = AsyncMock( return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} ) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() async def test_agents_info( @@ -364,6 +364,13 @@ async def test_agents_upload_fail( mock_api.resumable_upload_file = AsyncMock( side_effect=GoogleDriveApiError("some error") ) + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"id": "HA folder ID", "name": "HA folder name"}]}, + {"files": []}, + {"files": [{"id": "HA folder ID", "name": "HA folder name"}]}, + ] + ) client = await hass_client() diff --git a/tests/components/google_drive/test_diagnostic.py b/tests/components/google_drive/test_diagnostic.py new file mode 100644 index 0000000000000..006df85b51527 --- /dev/null +++ b/tests/components/google_drive/test_diagnostic.py @@ -0,0 +1,50 @@ +"""Test GIOS diagnostics.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +pytestmark = [ + pytest.mark.freeze_time("2021-11-04 17:36:59+01:00"), +] + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, + mock_agent_backup: AgentBackup, +) -> None: + """Test config entry diagnostics.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [ + { + "id": "HA folder ID", + "name": "HA folder name", + "description": json.dumps(mock_agent_backup.as_dict()), + } + ] + } + ) + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/google_drive/test_init.py b/tests/components/google_drive/test_init.py index 8173e00fb54f1..2c97455016d4f 100644 --- a/tests/components/google_drive/test_init.py +++ b/tests/components/google_drive/test_init.py @@ -41,10 +41,6 @@ async def test_setup_success( ) -> None: """Test successful setup and unload.""" # Setup looks up existing folder to make sure it still exists - mock_api.list_files = AsyncMock( - return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} - ) - await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) @@ -76,7 +72,7 @@ async def test_create_folder_if_missing( assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED - mock_api.list_files.assert_called_once() + assert mock_api.list_files.call_count == 2 mock_api.create_file.assert_called_once() @@ -104,10 +100,6 @@ async def test_expired_token_refresh_success( mock_api: MagicMock, ) -> None: """Test expired token is refreshed.""" - # Setup looks up existing folder to make sure it still exists - mock_api.list_files = AsyncMock( - return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} - ) aioclient_mock.post( "https://oauth2.googleapis.com/token", json={ diff --git a/tests/components/google_drive/test_sensor.py b/tests/components/google_drive/test_sensor.py new file mode 100644 index 0000000000000..6e9d959cfbcd5 --- /dev/null +++ b/tests/components/google_drive/test_sensor.py @@ -0,0 +1,143 @@ +"""Tests for the Google Drive sensor platform.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import AgentBackup +from homeassistant.components.google_drive.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +pytestmark = [ + pytest.mark.freeze_time("2021-11-04 17:36:59+01:00"), +] + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up Google Drive integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Test the creation and values of the Google Drive sensors.""" + await setup_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + assert ( + entity_entry := entity_registry.async_get( + "sensor.testuser_domain_com_total_available_storage" + ) + ) + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_unknown_when_unlimited_plan( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Test the total storage are unknown when the user is on an unlimited plan.""" + mock_api.get_user = AsyncMock( + return_value={ + "storageQuota": { + "limit": None, + "usage": "100", + "usageInDrive": "50", + "usageInTrash": "10", + } + } + ) + + assert not hass.states.get("sensor.testuser_domain_com_total_available_storage") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_availability( + hass: HomeAssistant, + mock_api: MagicMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the availability handling of the Google Drive sensors.""" + await setup_integration(hass, config_entry) + + mock_api.get_user.side_effect = GoogleDriveApiError("API error") + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + state := hass.states.get("sensor.testuser_domain_com_total_available_storage") + ) + assert state.state == STATE_UNAVAILABLE + + mock_api.list_files.side_effect = [{"files": []}] + mock_api.get_user.side_effect = None + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + state := hass.states.get("sensor.testuser_domain_com_total_available_storage") + ) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_calculate_backups_size( + hass: HomeAssistant, + mock_api: MagicMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_agent_backup: AgentBackup, +) -> None: + """Test the availability handling of the Google Drive sensors.""" + await setup_integration(hass, config_entry) + + assert ( + state := hass.states.get("sensor.testuser_domain_com_total_size_of_backups") + ) + assert state.state == "0.0" + + mock_api.list_files = AsyncMock( + return_value={ + "files": [ + { + "id": "HA folder ID", + "name": "HA folder name", + "description": json.dumps(mock_agent_backup.as_dict()), + } + ] + } + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + state := hass.states.get("sensor.testuser_domain_com_total_size_of_backups") + ) + assert state.state == "100.0"