From 239ba9b1cc5bfcbf80daeb6715676e81c5e7cd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 4 Feb 2025 13:54:50 +0100 Subject: [PATCH 01/18] Bump hass-nabucasa from 0.88.1 to 0.89.0 (#137321) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0f415b1738a..8e8ff4335db 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.88.1"], + "requirements": ["hass-nabucasa==0.89.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 91d73428f80..622497c6554 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 8fb18fa7f07..f80133b17c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.88.1", + "hass-nabucasa==0.89.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index a99034ee9cf..2eb9aa4252e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0f44da6d6f9..6b4e7a15441 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2dbd991472c..9a3c4926b86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 # homeassistant.components.conversation hassil==2.2.3 From b45d7cbbc3a0f7f4f5f6ce39f87fca31b222ed45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 6 Feb 2025 07:32:46 +0100 Subject: [PATCH 02/18] Move cloud backup upload/download handlers to lib (#137416) * Move cloud backup upload/download handlers to lib * Update backup.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/cloud/backup.py | 77 ++--------- tests/components/cloud/conftest.py | 2 + tests/components/cloud/test_backup.py | 166 ++++------------------- 3 files changed, 39 insertions(+), 206 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index d42e846259c..f6d24656ccb 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,16 +8,11 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any +from typing import Any, Literal -from aiohttp import ClientError, ClientTimeout +from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.cloud_api import ( - async_files_delete_file, - async_files_download_details, - async_files_list, - async_files_upload_details, -) +from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -28,7 +23,7 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP = "backup" +_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -109,63 +104,14 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Backup not found") try: - details = await async_files_download_details( - self._cloud, + content = await self._cloud.files.download( storage_type=_STORAGE_BACKUP, filename=self._get_backup_filename(), ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get download details") from err + except CloudError as err: + raise BackupAgentError(f"Failed to download backup: {err}") from err - try: - resp = await self._cloud.websession.get( - details["url"], - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - - resp.raise_for_status() - except ClientError as err: - raise BackupAgentError("Failed to download backup") from err - - return ChunkAsyncStreamIterator(resp.content) - - async def _async_do_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - filename: str, - base64md5hash: str, - metadata: dict[str, Any], - size: int, - ) -> None: - """Upload a backup.""" - try: - details = await async_files_upload_details( - self._cloud, - storage_type=_STORAGE_BACKUP, - filename=filename, - metadata=metadata, - size=size, - base64md5hash=base64md5hash, - ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get upload details") from err - - try: - upload_status = await self._cloud.websession.put( - details["url"], - data=await open_stream(), - headers=details["headers"] | {"content-length": str(size)}, - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - _LOGGER.log( - logging.DEBUG if upload_status.status < 400 else logging.WARNING, - "Backup upload status: %s", - upload_status.status, - ) - upload_status.raise_for_status() - except (TimeoutError, ClientError) as err: - raise BackupAgentError("Failed to upload backup") from err + return ChunkAsyncStreamIterator(content) async def async_upload_backup( self, @@ -190,7 +136,8 @@ class CloudBackupAgent(BackupAgent): tries = 1 while tries <= _RETRY_LIMIT: try: - await self._async_do_upload_backup( + await self._cloud.files.upload( + storage_type=_STORAGE_BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -198,9 +145,9 @@ class CloudBackupAgent(BackupAgent): size=size, ) break - except BackupAgentError as err: + except CloudError as err: if tries == _RETRY_LIMIT: - raise + raise BackupAgentError("Failed to upload backup") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 7002f7c39ec..276a06a7f46 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -9,6 +9,7 @@ from hass_nabucasa import Cloud from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.files import Files from hass_nabucasa.google_report_state import GoogleReportState from hass_nabucasa.ice_servers import IceServers from hass_nabucasa.iot import CloudIoT @@ -68,6 +69,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED ) mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None mock_cloud.ice_servers = MagicMock( spec=IceServers, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5b2b8751311..ba789e093ff 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -1,14 +1,14 @@ """Test the cloud backup platform.""" -from collections.abc import AsyncGenerator, AsyncIterator, Generator +from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.files import FilesError import pytest -from yarl import URL from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -22,11 +22,20 @@ from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -55,49 +64,6 @@ def mock_delete_file() -> Generator[MagicMock]: yield delete_file -@pytest.fixture -def mock_get_download_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_download_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" - ), - } - yield download_details - - -@pytest.fixture -def mock_get_upload_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_upload_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" - ), - "headers": { - "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", - "x-amz-meta-storage-type": "backup", - "x-amz-meta-b64json": ( - "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" - "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" - "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" - "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" - "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" - ), - }, - } - yield download_details - - @pytest.fixture def mock_list_files() -> Generator[MagicMock]: """Mock list files.""" @@ -264,52 +230,30 @@ async def test_agents_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get( - mock_get_download_details.return_value["url"], content=b"backup data" - ) + cloud.files.download.return_value = MockStreamReaderChunked(b"backup data") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_download_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_get_download_details: Mock, - side_effect: Exception, -) -> None: - """Test agent download backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "23e64aec" - mock_get_download_details.side_effect = side_effect - - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") - assert resp.status == 500 - content = await resp.content.read() - assert "Failed to get download details" in content.decode() - - @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_fail_get( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup, when cloud user is logged in.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + cloud.files.download.side_effect = FilesError("Oh no :(") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 500 @@ -336,8 +280,7 @@ async def test_agents_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, + cloud: Mock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -355,8 +298,6 @@ async def test_agents_upload( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"]) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -374,26 +315,22 @@ async def test_agents_upload( data={"file": StringIO("test")}, ) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][0] == "PUT" - assert aioclient_mock.mock_calls[-1][1] == URL( - mock_get_upload_details.return_value["url"] - ) - assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + assert len(cloud.files.upload.mock_calls) == 1 + metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] + assert metadata["backup_id"] == backup_id assert resp.status == 201 assert f"Uploading backup {backup_id}" in caplog.text -@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.parametrize("side_effect", [FilesError("Boom!"), CloudError("Boom!")]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_upload_fail_put( +async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, - put_mock_kwargs: dict[str, Any], + side_effect: Exception, + cloud: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" @@ -412,7 +349,8 @@ async def test_agents_upload_fail_put( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + cloud.files.upload.side_effect = side_effect with ( patch( @@ -435,7 +373,6 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() - assert len(aioclient_mock.mock_calls) == 2 assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] @@ -445,59 +382,6 @@ async def test_agents_upload_fail_put( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in") -async def test_agents_upload_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_storage: dict[str, Any], - mock_get_upload_details: Mock, - side_effect: Exception, -) -> None: - """Test agent upload backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "test-backup" - mock_get_upload_details.side_effect = side_effect - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=0, - ) - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.cloud.backup.asyncio.sleep"), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, - ) - await hass.async_block_till_done() - - assert resp.status == 201 - store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] - assert len(store_backups) == 1 - stored_backup = store_backups[0] - assert stored_backup["backup_id"] == backup_id - assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] - - async def test_agents_upload_not_protected( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 8dfe483b38384266411f0ed69604c6da39b4fff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 6 Feb 2025 09:57:10 +0100 Subject: [PATCH 03/18] Handle non-retryable errors when uploading cloud backup (#137517) --- homeassistant/components/cloud/backup.py | 13 +++- homeassistant/components/cloud/strings.json | 5 ++ tests/components/cloud/test_backup.py | 72 +++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index f6d24656ccb..9531604ccc7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -12,6 +12,7 @@ from typing import Any, Literal from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError +from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError @@ -145,9 +146,19 @@ class CloudBackupAgent(BackupAgent): size=size, ) break + except CloudApiNonRetryableError as err: + if err.code == "NC-SH-FH-03": + raise BackupAgentError( + translation_domain=DOMAIN, + translation_key="backup_size_too_large", + translation_placeholders={ + "size": str(round(size / (1024**3), 2)) + }, + ) from err + raise BackupAgentError(f"Failed to upload backup {err}") from err except CloudError as err: if tries == _RETRY_LIMIT: - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 1da91f67813..6380ee9c312 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -17,6 +17,11 @@ "subscription_expiration": "Subscription expiration" } }, + "exceptions": { + "backup_size_too_large": { + "message": "The backup size of {size}GB is too large to be uploaded to Home Assistant Cloud." + } + }, "issues": { "deprecated_gender": { "title": "The {deprecated_option} text-to-speech option is deprecated", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index ba789e093ff..6e59b7d983e 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.files import FilesError import pytest @@ -375,6 +376,77 @@ async def test_agents_upload_fail( assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 2 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.parametrize( + ("side_effect", "logmsg"), + [ + ( + CloudApiNonRetryableError("Boom!", code="NC-SH-FH-03"), + "The backup size of 13.37GB is too large to be uploaded to Home Assistant Cloud", + ), + ( + CloudApiNonRetryableError("Boom!", code="NC-CE-01"), + "Failed to upload backup Boom!", + ), + ], +) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_non_retryable( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + side_effect: Exception, + logmsg: str, + cloud: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent upload backup fails with non-retryable error.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=14358124749, + ) + + cloud.files.upload.side_effect = side_effect + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert logmsg in caplog.text + assert resp.status == 201 + assert cloud.files.upload.call_count == 1 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] From df49c53bb6e2c56163c06113643c90b4ecd5f4b5 Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 10:56:42 -0700 Subject: [PATCH 04/18] Add missing thermostat state EMERGENCY_HEAT to econet (#137623) * Add missing thermostat state EMERGENCY_HEAT to econet * econet: fix overloaded reverse dictionary * Update homeassistant/components/econet/climate.py --------- Co-authored-by: Robert Resch --- homeassistant/components/econet/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index d46dbd8750a..d1f3c24855e 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -35,8 +35,13 @@ ECONET_STATE_TO_HA = { ThermostatOperationMode.OFF: HVACMode.OFF, ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL, ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY, + ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT, +} +HA_STATE_TO_ECONET = { + value: key + for key, value in ECONET_STATE_TO_HA.items() + if key != ThermostatOperationMode.EMERGENCY_HEAT } -HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()} ECONET_FAN_STATE_TO_HA = { ThermostatFanMode.AUTO: FAN_AUTO, From b4ef00659c7447e8f2a2db03765e7a843aef8e8d Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 03:41:52 -0800 Subject: [PATCH 05/18] Fix broken issue creation in econet (#137773) * econet: Fix broken issue creation * econet: fix broken issue creation with create_issue --- homeassistant/components/econet/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index d1f3c24855e..da4d0601f07 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,7 +23,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry from .const import DOMAIN @@ -214,7 +214,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", @@ -228,7 +228,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", From f8763c49ef34801b27620bdeae37e5a3fbcf0197 Mon Sep 17 00:00:00 2001 From: "Andre W." <10945277+alfwro13@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:24:39 +0000 Subject: [PATCH 06/18] Fix version extraction for APsystems (#138023) Co-authored-by: Marlon --- homeassistant/components/apsystems/entity.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 7770b451680..c3e67f52c82 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -19,10 +19,20 @@ class ApSystemsEntity(Entity): data: ApSystemsData, ) -> None: """Initialize the APsystems entity.""" + + # Handle device version safely + sw_version = None + if data.coordinator.device_version: + version_parts = data.coordinator.device_version.split(" ") + if len(version_parts) > 1: + sw_version = version_parts[1] + else: + sw_version = version_parts[0] + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data.device_id)}, manufacturer="APsystems", model="EZ1-M", serial_number=data.device_id, - sw_version=data.coordinator.device_version.split(" ")[1], + sw_version=sw_version, ) From 9772014bce22c40aad7db0ef6cfc1de05d72836e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 02:51:30 -0800 Subject: [PATCH 07/18] Refresh nest access token before before building subscriber Credentials (#138259) --- homeassistant/components/nest/api.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 727b126dda4..d55826f7ed0 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -50,13 +50,14 @@ class AsyncConfigEntryAuth(AbstractAuth): return cast(str, self._oauth_session.token["access_token"]) async def async_get_creds(self) -> Credentials: - """Return an OAuth credential for Pub/Sub Subscriber.""" - # We don't have a way for Home Assistant to refresh creds on behalf - # of the google pub/sub subscriber. Instead, build a full - # Credentials object with enough information for the subscriber to - # handle this on its own. We purposely don't refresh the token here - # even when it is expired to fully hand off this responsibility and - # know it is working at startup (then if not, fail loudly). + """Return an OAuth credential for Pub/Sub Subscriber. + + The subscriber will call this when connecting to the stream to refresh + the token. We construct a credentials object using the underlying + OAuth2Session since the subscriber may expect the expiry fields to + be present. + """ + await self.async_get_access_token() token = self._oauth_session.token creds = Credentials( # type: ignore[no-untyped-call] token=token["access_token"], From 979b3d42691466051bef691db7b0044882a4a0ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 14:53:07 +0100 Subject: [PATCH 08/18] Fix BackupManager.async_delete_backup (#138286) --- homeassistant/components/backup/manager.py | 4 +- tests/components/backup/test_websocket.py | 233 ++++++++------------- 2 files changed, 93 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index e175ff9c03d..81826ffcb24 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -688,8 +688,8 @@ class BackupManager: delete_backup_results = await asyncio.gather( *( - agent.async_delete_backup(backup_id) - for agent in self.backup_agents.values() + self.backup_agents[agent_id].async_delete_backup(backup_id) + for agent_id in agent_ids ), return_exceptions=True, ) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 966cfbbef78..773256bdd0b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -6,7 +6,6 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest -from pytest_unordered import unordered from syrupy import SnapshotAssertion from homeassistant.components.backup import ( @@ -100,15 +99,6 @@ def mock_delay_save() -> Generator[None]: yield -@pytest.fixture(name="delete_backup") -def mock_delete_backup() -> Generator[AsyncMock]: - """Mock manager delete backup.""" - with patch( - "homeassistant.components.backup.BackupManager.async_delete_backup" - ) as mock_delete_backup: - yield mock_delete_backup - - @pytest.fixture(name="get_backups") def mock_get_backups() -> Generator[AsyncMock]: """Mock manager get backups.""" @@ -911,7 +901,7 @@ async def test_agents_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "storage_data", [ @@ -1161,7 +1151,7 @@ async def test_config_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "commands", [ @@ -1326,7 +1316,7 @@ async def test_config_update( assert hass_storage[DOMAIN] == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "command", [ @@ -1783,14 +1773,13 @@ async def test_config_schedule_logic( "command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "agent_delete_backup_side_effects", "last_backup_time", "next_time", "backup_time", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -1833,8 +1822,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { @@ -1876,8 +1864,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { @@ -1907,8 +1894,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { @@ -1971,13 +1957,10 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ) - ], + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { @@ -2039,13 +2022,10 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ) - ], + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { @@ -2093,11 +2073,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( { @@ -2132,15 +2108,14 @@ async def test_config_schedule_logic( spec=ManagerBackup, ), }, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, {}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2176,14 +2151,13 @@ async def test_config_schedule_logic( ), }, {}, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2246,21 +2220,18 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-2", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-3", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - ], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + }, ), ( { @@ -2322,18 +2293,14 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-2", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call("backup-3", agent_ids=["test.test-agent"]), - ], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, ), ( { @@ -2363,8 +2330,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ], ) @@ -2375,19 +2341,17 @@ async def test_config_retention_copies_logic( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + agent_delete_backup_side_effects: dict[str, Exception], last_backup_time: str, next_time: str, backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic.""" created_backup: MagicMock = create_backup.return_value[1].result().backup @@ -2425,13 +2389,18 @@ async def test_config_retention_copies_logic( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + await setup_backup_integration(hass, remote_agents=["test-agent", "test-agent2"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent_id, agent in manager.backup_agents.items(): + agent.async_delete_backup = AsyncMock( + side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True + ) + await client.send_json_auto_id(command) result = await client.receive_json() @@ -2442,8 +2411,10 @@ async def test_config_retention_copies_logic( await hass.async_block_till_done() assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2474,11 +2445,9 @@ async def test_config_retention_copies_logic( "config_command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -2515,11 +2484,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { @@ -2555,11 +2522,9 @@ async def test_config_retention_copies_logic( ), }, {}, + 1, + 1, {}, - 1, - 1, - 0, - [], ), ( { @@ -2601,11 +2566,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2647,14 +2610,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -2664,18 +2622,15 @@ async def test_config_retention_copies_logic_manual_backup( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, config_command: dict[str, Any], backup_command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic for manual backup.""" created_backup: MagicMock = create_backup.return_value[1].result().backup @@ -2713,13 +2668,16 @@ async def test_config_retention_copies_logic_manual_backup( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent in manager.backup_agents.values(): + agent.async_delete_backup = AsyncMock(autospec=True) + await client.send_json_auto_id(config_command) result = await client.receive_json() assert result["success"] @@ -2734,8 +2692,10 @@ async def test_config_retention_copies_logic_manual_backup( assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2754,13 +2714,12 @@ async def test_config_retention_copies_logic_manual_backup( "commands", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "agent_delete_backup_side_effects", "last_backup_time", "start_time", "next_time", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ # No config update - cleanup backups older than 2 days @@ -2793,8 +2752,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), # No config update - No cleanup ( @@ -2826,8 +2784,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 0, - 0, - [], + {}, ), # Unchanged config ( @@ -2866,8 +2823,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2905,8 +2861,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2944,8 +2899,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 0, - [], + {}, ), ( None, @@ -2989,11 +2943,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( None, @@ -3031,8 +2981,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -3070,8 +3019,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -3115,11 +3063,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -3128,19 +3072,17 @@ async def test_config_retention_days_logic( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], - delete_backup: AsyncMock, get_backups: AsyncMock, stored_retained_days: int | None, commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + agent_delete_backup_side_effects: dict[str, Exception], last_backup_time: str, start_time: str, next_time: str, get_backups_calls: int, - delete_calls: int, - delete_args_list: list[Any], + delete_calls: dict[str, Any], ) -> None: """Test config backup retention logic.""" client = await hass_ws_client(hass) @@ -3175,13 +3117,18 @@ async def test_config_retention_days_logic( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to(start_time) - await setup_backup_integration(hass) + await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent_id, agent in manager.backup_agents.items(): + agent.async_delete_backup = AsyncMock( + side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True + ) + for command in commands: await client.send_json_auto_id(command) result = await client.receive_json() @@ -3191,8 +3138,10 @@ async def test_config_retention_days_logic( async_fire_time_changed(hass) await hass.async_block_till_done() assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() From 7e52170789c8e1a6a5741eb1c156dfc46f2bfbea Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 12:47:36 -0800 Subject: [PATCH 09/18] Fix next authentication token error handling (#138299) --- homeassistant/components/nest/__init__.py | 13 +++--- tests/components/nest/test_init.py | 54 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 67c14bbf544..af85f1fc5ae 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import logging -from aiohttp import web +from aiohttp import ClientError, ClientResponseError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage @@ -201,11 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool auth = await api.new_auth(hass, entry) try: await auth.async_get_access_token() - except AuthException as err: - raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err - except ConfigurationException as err: - _LOGGER.error("Configuration error: %s", err) - return False + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 7d04624dcc8..c7ac5875403 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -9,10 +9,12 @@ relevant modes. """ from collections.abc import Generator +import datetime from http import HTTPStatus import logging from unittest.mock import AsyncMock, patch +import aiohttp from google_nest_sdm.exceptions import ( ApiException, AuthException, @@ -22,6 +24,7 @@ from google_nest_sdm.exceptions import ( import pytest from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -36,6 +39,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker PLATFORM = "sensor" +EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() + @pytest.fixture def platforms() -> list[str]: @@ -139,6 +144,55 @@ async def test_setup_device_manager_failure( assert entries[0].state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("token_expiration_time", [EXPIRED_TOKEN_TIMESTAMP]) +@pytest.mark.parametrize( + ("token_response_args", "expected_state", "expected_steps"), + [ + # Cases that retry integration setup + ( + {"status": HTTPStatus.INTERNAL_SERVER_ERROR}, + ConfigEntryState.SETUP_RETRY, + [], + ), + ({"exc": aiohttp.ClientError("No internet")}, ConfigEntryState.SETUP_RETRY, []), + # Cases that require the user to reauthenticate in a config flow + ( + {"status": HTTPStatus.BAD_REQUEST}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ( + {"status": HTTPStatus.FORBIDDEN}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ], +) +async def test_expired_token_refresh_error( + hass: HomeAssistant, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + token_response_args: dict, + expected_state: ConfigEntryState, + expected_steps: list[str], +) -> None: + """Test errors when attempting to refresh the auth token.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + **token_response_args, + ) + + await setup_base_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is expected_state + + flows = hass.config_entries.flow.async_progress() + assert expected_steps == [flow["step_id"] for flow in flows] + + @pytest.mark.parametrize("subscriber_side_effect", [AuthException()]) async def test_subscriber_auth_failure( hass: HomeAssistant, From 2cb9682303c4644107b5a118c11f08c18f6f9c06 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:35:03 +0100 Subject: [PATCH 10/18] Bump pyenphase to 1.25.1 (#138327) * Bump pyenphase to 1.25.1 * Add new opt_schedules to nephase_envoy test fixtures --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/enphase_envoy/fixtures/envoy_1p_metered.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_acb_batt.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_eu_batt.json | 3 ++- .../enphase_envoy/fixtures/envoy_metered_batt_relay.json | 3 ++- .../enphase_envoy/fixtures/envoy_nobatt_metered_3p.json | 3 ++- .../enphase_envoy/fixtures/envoy_tot_cons_metered.json | 3 ++- 9 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0b1fd8b04b9..e51a7427504 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.23.1"], + "requirements": ["pyenphase==1.25.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 6b4e7a15441..f2d81906a2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1930,7 +1930,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a3c4926b86..f0c6564cc9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1574,7 +1574,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 05a6f265dfb..22aeca50ca0 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -93,7 +93,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 618b40027b8..52e812f979e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -235,7 +235,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 8118630200f..30fbc8d0f4f 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -223,7 +223,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 7affc1bea0d..6cfbfed1e8e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -427,7 +427,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index ff975b690ed..8c2767e33e5 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -242,7 +242,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 62df69c6d88..15cf2c173cb 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -88,7 +88,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, From 288acfb51125f9539e415a86d5c63142657f3be7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 01:04:05 +0100 Subject: [PATCH 11/18] Bump sentry-sdk to 1.45.1 (#138349) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 425225e07ef..4c3a7518085 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.40.3"] + "requirements": ["sentry-sdk==1.45.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2d81906a2d..15973545b5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0c6564cc9e..ce9695b65e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 From b166c32eb85a15a881afcf2f2c30746ec98a3526 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Feb 2025 08:57:26 -0600 Subject: [PATCH 12/18] Bump zeroconf to 0.144.1 (#138353) * Bump zeroconf to 0.143.1 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.143.0...0.143.1 fixes #138324 fixes https://github.com/home-assistant/core/issues/137731 fixes https://github.com/home-assistant/core/issues/138298 * one more --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index f4a78cd99e9..ddc74fba8bf 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.143.0"] + "requirements": ["zeroconf==0.144.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 622497c6554..64268be2ca2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.143.0 +zeroconf==0.144.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index f80133b17c9..47a16ea9284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.143.0" + "zeroconf==0.144.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2eb9aa4252e..4eda126171b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.143.0 +zeroconf==0.144.1 diff --git a/requirements_all.txt b/requirements_all.txt index 15973545b5b..3ee3e2df40e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce9695b65e8..ea1e8ee1c62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From 41fb6a537f5601430864b46825994dc52f09301f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 12:46:53 +0100 Subject: [PATCH 13/18] Bump cryptography to 44.0.1 (#138371) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64268be2ca2..f5e8fcdaf6f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -cryptography==44.0.0 +cryptography==44.0.1 dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 diff --git a/pyproject.toml b/pyproject.toml index 47a16ea9284..97ca6d9b047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.0", + "cryptography==44.0.1", "Pillow==11.1.0", "propcache==0.2.1", "pyOpenSSL==24.3.0", diff --git a/requirements.txt b/requirements.txt index 4eda126171b..be815a2dd58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 -cryptography==44.0.0 +cryptography==44.0.1 Pillow==11.1.0 propcache==0.2.1 pyOpenSSL==24.3.0 From 5a257b090eeffc64fc4bde5ad24c8f6539712db4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:46:11 +0000 Subject: [PATCH 14/18] Fix tplink iot strip sensor refresh (#138375) --- .../components/tplink/coordinator.py | 20 ++++++------------- homeassistant/components/tplink/entity.py | 16 +++++++-------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index fcd1335a77a..1a7b40457f0 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -9,6 +9,7 @@ import logging from kasa import AuthenticationError, Credentials, Device, KasaException from kasa.iot import IotStrip +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -46,11 +47,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, - parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device - self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -97,12 +96,6 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() - if not self._update_children: - # If the children are not being updated, it means this is an - # IotStrip, and we need to tell the children to write state - # since the power state is provided by the parent. - for child_coordinator in self._child_coordinators.values(): - child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -131,20 +124,19 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): def get_child_coordinator( self, child: Device, + platform_domain: str, ) -> TPLinkDataUpdateCoordinator: """Get separate child coordinator for a device or self if not needed.""" # The iot HS300 allows a limited number of concurrent requests and fetching the # emeter information requires separate ones so create child coordinators here. - if isinstance(self.device, IotStrip): + # This does not happen for switches as the state is available on the + # parent device info. + if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN: if not (child_coordinator := self._child_coordinators.get(child.device_id)): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, - child, - timedelta(seconds=60), - self.config_entry, - parent_coordinator=self, + self.hass, child, timedelta(seconds=60), self.config_entry ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 7a0d811b30d..7c1e9e72b85 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,13 +151,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - coordinator = self.coordinator - if coordinator.parent_coordinator: - # If there is a parent coordinator we need to refresh - # the parent as its what provides the power state data - # for the child entities. - coordinator = coordinator.parent_coordinator - await coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() return _async_wrap @@ -514,7 +508,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities = cls._entities_for_device( hass, @@ -657,7 +653,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): device.host, ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities: list[_E] = cls._entities_for_device( hass, From 0faa8efd5ac29eac6e89fa7d76ee1bde94fc8606 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 14:14:52 +0100 Subject: [PATCH 15/18] Bump deebot-client to 12.1.0 (#138382) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 33a251c22dc..79e0c34e4b9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ee3e2df40e..1d8b788fd9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea1e8ee1c62..f4ce10bc3e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From a9c6a0670402b9f03a2aa526ab340f9d5b6f4239 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 15:29:42 +0100 Subject: [PATCH 16/18] Bump hass-nabucasa from 0.89.0 to 0.90.0 (#138387) * Bump hass-nabucasa from 0.89.0 to 0.90.0 * Use new shiny enum --- homeassistant/components/cloud/backup.py | 14 ++++++++------ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 9531604ccc7..83dc44c0ef7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,12 +8,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any, Literal +from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.files import StorageType from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -24,7 +25,6 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -106,7 +106,7 @@ class CloudBackupAgent(BackupAgent): try: content = await self._cloud.files.download( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except CloudError as err: @@ -138,7 +138,7 @@ class CloudBackupAgent(BackupAgent): while tries <= _RETRY_LIMIT: try: await self._cloud.files.upload( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -185,7 +185,7 @@ class CloudBackupAgent(BackupAgent): try: await async_files_delete_file( self._cloud, - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except (ClientError, CloudError) as err: @@ -194,7 +194,9 @@ class CloudBackupAgent(BackupAgent): async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: - backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + backups = await async_files_list( + self._cloud, storage_type=StorageType.BACKUP + ) _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8e8ff4335db..1335d9b81bf 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.89.0"], + "requirements": ["hass-nabucasa==0.90.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5e8fcdaf6f..2855d41de04 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 97ca6d9b047..32e7594894a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.89.0", + "hass-nabucasa==0.90.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index be815a2dd58..26626ca9fcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1d8b788fd9d..c0852e92e35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4ce10bc3e9..ae91df10624 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.conversation hassil==2.2.3 From 4b5633d9d8f0abb03453eed2abd69ddf7f879d90 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 19:11:20 +0100 Subject: [PATCH 17/18] Update cloud backup agent to use calculate_b64md5 from lib (#138391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update cloud backup agent to use calculate_b64md5 from lib * Catch error, add test * Address review comments * Update tests/components/cloud/test_backup.py Co-authored-by: Abílio Costa --------- Co-authored-by: Abílio Costa --- homeassistant/components/cloud/backup.py | 19 ++----- tests/components/cloud/test_backup.py | 72 ++++++++++++++++++++---- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 83dc44c0ef7..61edeccdd9c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -import base64 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping -import hashlib import logging import random from typing import Any @@ -14,7 +12,7 @@ from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list -from hass_nabucasa.files import StorageType +from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -30,14 +28,6 @@ _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 -async def _b64md5(stream: AsyncIterator[bytes]) -> str: - """Calculate the MD5 hash of a file.""" - file_hash = hashlib.md5() - async for chunk in stream: - file_hash.update(chunk) - return base64.b64encode(file_hash.digest()).decode() - - async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -129,10 +119,13 @@ class CloudBackupAgent(BackupAgent): if not backup.protected: raise BackupAgentError("Cloud backups must be protected") - base64md5hash = await _b64md5(await open_stream()) + size = backup.size + try: + base64md5hash = await calculate_b64md5(open_stream, size) + except FilesError as err: + raise BackupAgentError(err) from err filename = self._get_backup_filename() metadata = backup.as_dict() - size = backup.size tries = 1 while tries <= _RETRY_LIMIT: diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 6e59b7d983e..c6bb0bdad54 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -285,6 +285,7 @@ async def test_agents_upload( ) -> None: """Test agent upload backup.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -297,7 +298,7 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) with ( patch( @@ -309,11 +310,11 @@ async def test_agents_upload( ), patch("pathlib.Path.open") as mocked_open, ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) assert len(cloud.files.upload.mock_calls) == 1 @@ -336,6 +337,7 @@ async def test_agents_upload_fail( ) -> None: """Test agent upload backup fails.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -348,7 +350,7 @@ async def test_agents_upload_fail( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) cloud.files.upload.side_effect = side_effect @@ -366,11 +368,11 @@ async def test_agents_upload_fail( patch("homeassistant.components.cloud.backup.random.randint", return_value=60), patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -409,6 +411,7 @@ async def test_agents_upload_fail_non_retryable( ) -> None: """Test agent upload backup fails with non-retryable error.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -435,12 +438,13 @@ async def test_agents_upload_fail_non_retryable( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.calculate_b64md5"), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -461,6 +465,7 @@ async def test_agents_upload_not_protected( ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -473,7 +478,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0, + size=len(backup_data), ) with ( patch("pathlib.Path.open"), @@ -484,7 +489,7 @@ async def test_agents_upload_not_protected( ): resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -496,6 +501,53 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_wrong_size( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + cloud: Mock, +) -> None: + """Test agent upload backup with the wrong size.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data) - 1, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + + assert len(cloud.files.upload.mock_calls) == 0 + + assert resp.status == 201 + assert "Upload failed for cloud.cloud" in caplog.text + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete( hass: HomeAssistant, From e5fd08ae762857bca1d24de2c8b61b28ddef4148 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 12 Feb 2025 19:00:55 +0000 Subject: [PATCH 18/18] Bump version to 2025.2.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bf9e76df60d..6d16b877e67 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 32e7594894a..1bc3c999421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.2" +version = "2025.2.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"