From 8749210d1b2b065e5da837971b26a08053f85e9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 001/178] Add support for per-backup agent encryption flag to hassio (#136828) * Add support for per-backup agent encryption flag to hassio * Improve comment * Set password to None when supervisor should not encrypt --- homeassistant/components/hassio/backup.py | 89 +++++-- tests/components/hassio/test_backup.py | 282 +++++++++++++++++++++- 2 files changed, 350 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 5318e4cd351..afeee1f4469 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -97,7 +97,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, + details: supervisor_backups.BackupComplete, location: str | None ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -109,6 +109,7 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -119,8 +120,8 @@ def _backup_details_to_agent_backup( homeassistant_included=homeassistant_included, homeassistant_version=details.homeassistant, name=details.name, - protected=details.protected, - size=details.size_bytes, + protected=details.location_attributes[location].protected, + size=details.location_attributes[location].size_bytes, ) @@ -158,8 +159,23 @@ class SupervisorBackupAgent(BackupAgent): ) -> None: """Upload a backup. - Not required for supervisor, the SupervisorBackupReaderWriter stores files. + The upload will be skipped if the backup already exists in the agent's location. """ + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return + stream = await open_stream() + upload_options = supervisor_backups.UploadBackupOptions( + location={self.location} + ) + await self._client.backups.upload_backup( + stream, + upload_options, + ) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" @@ -169,7 +185,7 @@ class SupervisorBackupAgent(BackupAgent): if not backup.locations or self.location not in backup.locations: continue details = await self._client.backups.backup_info(backup.slug) - result.append(_backup_details_to_agent_backup(details)) + result.append(_backup_details_to_agent_backup(details, self.location)) return result async def async_get_backup( @@ -181,7 +197,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) if self.location not in details.locations: return None - return _backup_details_to_agent_backup(details) + return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Remove a backup.""" @@ -246,7 +262,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = [agent.location for agent in hassio_agents] + + # Supervisor does not support creating backups spread across multiple + # locations, where some locations are encrypted and some are not. + # It's inefficient to let core do all the copying so we want to let + # supervisor handle as much as possible. + # Therefore, we split the locations into two lists: encrypted and decrypted. + # The longest list will be sent to supervisor, and the remaining locations + # will be handled by async_upload_backup. + # If the lists are the same length, it does not matter which one we send, + # we send the encrypted list to have a well defined behavior. + encrypted_locations: list[str | None] = [] + decrypted_locations: list[str | None] = [] + agents_settings = manager.config.data.agents + for hassio_agent in hassio_agents: + if password is not None: + if agent_settings := agents_settings.get(hassio_agent.agent_id): + if agent_settings.protected: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + else: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + _LOGGER.debug("Encrypted locations: %s", encrypted_locations) + _LOGGER.debug("Decrypted locations: %s", decrypted_locations) + if hassio_agents: + if len(encrypted_locations) >= len(decrypted_locations): + locations = encrypted_locations + else: + locations = decrypted_locations + password = None + else: + locations = [] + locations = locations or [LOCATION_CLOUD_BACKUP] try: backup = await self._client.backups.partial_backup( @@ -257,7 +307,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): name=backup_name, password=password, compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, + location=locations, homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, @@ -267,7 +317,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( - backup, remove_after_upload=not bool(locations) + backup, + locations, + remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return @@ -276,7 +328,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return (NewBackup(backup_job_id=backup.job_id), backup_task) async def _async_wait_for_backup( - self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + self, + backup: supervisor_backups.NewBackup, + locations: list[str | None], + *, + remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() @@ -327,7 +383,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -347,20 +403,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = {agent.location for agent in hassio_agents} + locations = [agent.location for agent in hassio_agents] + locations = locations or [LOCATION_CLOUD_BACKUP] backup_id = await self._client.backups.upload_backup( stream, - supervisor_backups.UploadBackupOptions( - location=locations or {LOCATION_CLOUD_BACKUP} - ), + supervisor_backups.UploadBackupOptions(location=set(locations)), ) async def open_backup() -> AsyncIterator[bytes]: return await self._client.backups.download_backup(backup_id) async def remove_backup() -> None: - if locations: + if locations != [LOCATION_CLOUD_BACKUP]: return await self._client.backups.remove_backup( backup_id, @@ -372,7 +427,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1c257416ad0..7c2bf8921ef 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -245,6 +245,56 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( type=TEST_BACKUP.type, ) +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=LOCATION_CLOUD_BACKUP, + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + locations={LOCATION_CLOUD_BACKUP}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP_5.location, + location_attributes=TEST_BACKUP_5.location_attributes, + locations=TEST_BACKUP_5.locations, + name=TEST_BACKUP_5.name, + protected=TEST_BACKUP_5.protected, + repositories=[], + size=TEST_BACKUP_5.size, + size_bytes=TEST_BACKUP_5.size_bytes, + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -821,6 +871,230 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [None, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [None], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [None, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [None], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + locations=create_locations, + location_attributes={ + location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=TEST_BACKUP_DETAILS.size_bytes, + ) + for location in create_locations + }, + ) + supervisor_client.mounts.info.return_value = mounts + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), @@ -969,7 +1243,7 @@ async def test_reader_writer_create_download_remove_error( """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1129,7 +1403,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1163,7 +1437,7 @@ async def test_reader_writer_create_remote_backup( assert response["result"] == {"backup_job_id": "abc123"} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1280,7 +1554,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], From 1d196e1b1f60402b19445a09da4313a652fb4a86 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 18:22:41 +0100 Subject: [PATCH 002/178] Bump version to 2025.2.0b0 --- 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 699aebcafdf..3fc165526ee 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 = "0.dev0" +PATCH_VERSION: Final = "0b0" __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 5393193a41e..55d7e7d2231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.2.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6247a847bf9ae912ab152397e20e47df3591b644 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 19:55:02 +0100 Subject: [PATCH 003/178] Persist hassio backup restore status after core restart (#136857) * Persist hassio backup restore status after core restart * Remove useless condition --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 43 ++++++++++++ tests/components/conftest.py | 1 + tests/components/hassio/test_backup.py | 74 ++++++++++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d3903c2d679..3003f94c2ed 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -31,6 +31,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -54,6 +55,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupState", "WrittenBackup", "async_get_manager", ] diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index afeee1f4469..6b63ab92d5c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging +import os from pathlib import Path from typing import Any, cast +from uuid import UUID from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( @@ -33,6 +35,7 @@ from homeassistant.components.backup import ( IncorrectPasswordError, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, ) @@ -47,6 +50,7 @@ from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" _LOGGER = logging.getLogger(__name__) @@ -518,6 +522,37 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" + if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + _LOGGER.debug("No restore job ID found in environment") + return + + _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup restore progress.""" + if data.get("done") is not True: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + ) + ) + return + + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) + ) + on_progress(IdleEvent()) + unsub() + + unsub = self._async_listen_job_events(restore_job_id, on_job_progress) + try: + await self._get_job_state(restore_job_id, on_job_progress) + except SupervisorError as err: + _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) + unsub() @callback def _async_listen_job_events( @@ -546,6 +581,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return unsub + async def _get_job_state( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> None: + """Poll a job for its state.""" + job = await self._client.jobs.get_job(UUID(job_id)) + _LOGGER.debug("Job state: %s", job) + on_event(job.to_dict()) + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 0cd33e28d35..ebf390e30d7 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7c2bf8921ef..49360783517 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -13,6 +13,7 @@ from io import StringIO import os from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo @@ -35,7 +37,11 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL +from homeassistant.components.hassio.backup import ( + LOCATION_CLOUD_BACKUP, + LOCATION_LOCAL, + RESTORE_JOB_ID_ENV, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -1802,3 +1808,69 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], + ) + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" From d338b0a2ffa4374c89d9feb8e6d6a9b5e7e2ef09 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Jan 2025 12:52:32 -0600 Subject: [PATCH 004/178] Cancel call if user does not pick up (#136858) --- .../components/voip/assist_satellite.py | 64 ++++++++++++++----- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/test_voip.py | 40 ++++++++++++ 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 738c3a1e235..6cacdd79af4 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final import wave from voip_utils import SIP_PORT, RtpDatagramProtocol -from voip_utils.sip import SipEndpoint, get_sip_endpoint +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 _ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -116,7 +117,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_done = asyncio.Event() + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None @@ -170,7 +172,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ - self._announcement_done.clear() + self._announcement_future = asyncio.Future() if self._rtp_port is None: # Choose random port for RTP @@ -194,16 +196,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol host=self.voip_device.voip_id, port=SIP_PORT ) + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() self._announcement = announcement # Make the call - self.hass.data[DOMAIN].protocol.outgoing_call( + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, ) - await self._announcement_done.wait() + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise async def _check_announcement_ended(self) -> None: """Continuously checks if an audio chunk was received within a time limit. @@ -211,12 +231,32 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol If not, the caller is presumed to have hung up and the announcement is ended. """ while self._announcement is not None: + current_time = time.monotonic() + _LOGGER.debug( + "%s %s %s", + self._last_chunk_time, + current_time, + self._announcment_start_time, + ) + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + if (self._last_chunk_time is not None) and ( - (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC ): # Caller hung up self._announcement = None - self._announcement_done.set() + self._announcement_future.set_result(None) self._check_announcement_ended_task = None _LOGGER.debug("Announcement ended") break @@ -248,16 +288,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._audio_queue.put_nowait(audio_bytes) elif self._run_pipeline_task is None: # Announcement only - if self._check_announcement_ended_task is None: - # Check if caller hung up - self._check_announcement_ended_task = ( - self.config_entry.async_create_background_task( - self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", - ) - ) - # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index b279665a03a..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.3.0"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c87835f9153..9e6da1045a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 968eec09d28..76ae46099c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2407,7 +2407,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index ac7c295c934..306857a1a44 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address( await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) From 0f97747d276093141124988d353799230d9d1087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 18:51:09 +0000 Subject: [PATCH 005/178] Handle locked account error in Whirlpool (#136861) --- homeassistant/components/whirlpool/__init__.py | 6 +++++- homeassistant/components/whirlpool/config_flow.py | 4 +++- homeassistant/components/whirlpool/strings.json | 9 +++++++++ tests/components/whirlpool/test_config_flow.py | 3 +++ tests/components/whirlpool/test_init.py | 13 +++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 44445dee03f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,7 +9,7 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -55,6 +55,8 @@ async def authenticate( try: await auth.do_auth() + except WhirlpoolAccountLocked: + return "account_locked" except (TimeoutError, ClientError): return "cannot_connect" except Exception: diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_error%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index a82c2a22695..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -82,6 +83,7 @@ async def test_form_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), @@ -249,6 +251,7 @@ async def test_reauth_flow_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, From 9c0fa327a6a8708ab50b09a8d7137ead6271ea77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jan 2025 09:09:00 -1000 Subject: [PATCH 006/178] Fix incorrect Bluetooth source address when restoring data from D-Bus (#136862) --- homeassistant/components/bluetooth/util.py | 10 +++++++++- tests/components/bluetooth/test_manager.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index ca2e0180c00..738a61b6f33 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -39,6 +39,10 @@ def async_load_history_from_system( now_monotonic = monotonic_time_coarse() connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} + adapter_to_source_address = { + adapter: details[ADAPTER_ADDRESS] + for adapter, details in adapters.adapters.items() + } # Restore local adapters for address, history in adapters.history.items(): @@ -50,7 +54,11 @@ def async_load_history_from_system( BluetoothServiceInfoBleak.from_device_and_advertisement_data( history.device, history.advertisement_data, - history.source, + # history.source is really the adapter name + # for historical compatibility since BlueZ + # does not know the MAC address of the adapter + # so we need to convert it to the source address (MAC) + adapter_to_source_address.get(history.source, history.source), now_monotonic, True, ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index c7fc80ba068..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -426,7 +426,7 @@ async def test_restore_history_from_dbus( address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), - HCI0_SOURCE_ADDRESS, + "hci0", ) } @@ -438,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") From 49b90fc140e17a88477523db44ca4624c81b6d8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 21:58:06 +0100 Subject: [PATCH 007/178] Bump backup store to version 1.3 (#136870) Co-authored-by: Paulus Schoutsen --- homeassistant/components/backup/store.py | 8 ++++-- .../backup/snapshots/test_store.ambr | 8 +++--- .../backup/snapshots/test_websocket.ambr | 28 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 3e2a88b8168..9b4af823c77 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 class StoredBackupData(TypedDict): @@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]): """Migrate to the new version.""" data = old_data if old_major_version == 1: - if old_minor_version < 2: - # Version 1.2 adds per agent settings, configurable backup time + if old_minor_version < 3: + # Version 1.2 bumped to 1.3 because 1.2 was changed several + # times during development. + # Version 1.3 adds per agent settings, configurable backup time # and custom days data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 7069860638a..2fd81d6841a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -39,7 +39,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -84,7 +84,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -179,7 +179,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 7ea911496de..08c19906241 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -664,7 +664,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -778,7 +778,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -892,7 +892,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1016,7 +1016,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1183,7 +1183,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1297,7 +1297,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1413,7 +1413,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1527,7 +1527,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1645,7 +1645,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1767,7 +1767,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1881,7 +1881,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1995,7 +1995,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2109,7 +2109,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2223,7 +2223,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- From 9c8d31a3d5c33af3e4c6847612471501136ad691 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:18:11 +0000 Subject: [PATCH 008/178] Bump version to 2025.2.0b1 --- 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 3fc165526ee..77b223fcbcf 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 = "0b0" +PATCH_VERSION: Final = "0b1" __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 55d7e7d2231..a592b8a194d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b0" +version = "2025.2.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f391438d0ad6e8c70403fa0b9319c1226d4e03a2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:32:10 -0500 Subject: [PATCH 009/178] Add start_conversation service to Assist Satellite (#134921) * Add start_conversation service to Assist Satellite * Fix tests * Implement start_conversation in voip * Update homeassistant/components/assist_satellite/entity.py --------- Co-authored-by: Michael Hansen --- .../components/assist_pipeline/pipeline.py | 1 + .../components/assist_satellite/__init__.py | 15 ++ .../components/assist_satellite/const.py | 3 + .../components/assist_satellite/entity.py | 59 +++++++- .../components/assist_satellite/icons.json | 3 + .../components/assist_satellite/services.yaml | 20 +++ .../components/assist_satellite/strings.json | 18 +++ .../components/voip/assist_satellite.py | 38 ++++-- homeassistant/helpers/service.py | 2 + tests/components/assist_satellite/conftest.py | 15 +- .../assist_satellite/test_entity.py | 128 ++++++++++++++++-- tests/components/voip/test_voip.py | 107 ++++++++++++++- 12 files changed, 384 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9fdcc2bf690..1d320d79bf2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1122,6 +1122,7 @@ class PipelineRun: context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, ) speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 47b0123a244..038ff517264 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( + "start_conversation", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("start_message"): str, + vol.Optional("start_media_id"): str, + vol.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 61ac7ecb39d..f7ac7e524b4 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag): ANNOUNCE = 1 """Device supports remotely triggered announcements.""" + + START_CONVERSATION = 2 + """Device supports starting conversations.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e9a5d22c0d0..927229c9756 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -10,7 +10,7 @@ import logging import time from typing import Any, Final, Literal, final -from homeassistant.components import media_source, stt, tts +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, AudioSettings, @@ -27,6 +27,7 @@ from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription @@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False + _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None @@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity): """ raise NotImplementedError + async def async_internal_start_conversation( + self, + start_message: str | None = None, + start_media_id: str | None = None, + extra_system_prompt: str | None = None, + ) -> None: + """Start a conversation from the satellite. + + If start_media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If start_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + # The Home Assistant built-in agent doesn't support conversations. + pipeline = async_get_pipeline(self.hass, self._resolve_pipeline()) + if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT: + raise HomeAssistantError( + "Built-in conversation agent does not support starting conversations" + ) + + if start_message is None: + start_message = "" + + announcement = await self._resolve_announcement_media_id( + start_message, start_media_id + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + # Provide our start info to the LLM so it understands context of incoming message + if extra_system_prompt is not None: + self._extra_system_prompt = extra_system_prompt + else: + self._extra_system_prompt = start_message or None + + try: + await self.async_start_conversation(announcement) + finally: + self._is_announcing = False + self._extra_system_prompt = None + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + raise NotImplementedError + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, + conversation_extra_system_prompt=self._extra_system_prompt, ), f"{self.entity_id}_pipeline", ) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index a98c3aefc5b..1ed29541621 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -7,6 +7,9 @@ "services": { "announce": { "service": "mdi:bullhorn" + }, + "start_conversation": { + "service": "mdi:forum" } } } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index e7fefc4705f..89a20ada6f3 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,3 +14,23 @@ announce: required: false selector: text: +start_conversation: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + fields: + start_message: + required: false + example: "You left the lights on in the living room. Turn them off?" + selector: + text: + start_media_id: + required: false + selector: + text: + extra_system_prompt: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 7f1426ef529..e83f4666b5d 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -25,6 +25,24 @@ "description": "The media ID to announce instead of using text-to-speech." } } + }, + "start_conversation": { + "name": "Start Conversation", + "description": "Start a conversation from a satellite.", + "fields": { + "start_message": { + "name": "Message", + "description": "The message to start with." + }, + "start_media_id": { + "name": "Media ID", + "description": "The media ID to start with instead of using text-to-speech." + }, + "extra_system_prompt": { + "name": "Extra system prompt", + "description": "Provide background information to the AI about the request." + } + } } } } diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6cacdd79af4..1877b8c655c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -90,7 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -122,6 +125,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False @property def pipeline_entity_id(self) -> str | None: @@ -172,7 +176,17 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: # Choose random port for RTP @@ -232,12 +246,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """ while self._announcement is not None: current_time = time.monotonic() - _LOGGER.debug( - "%s %s %s", - self._last_chunk_time, - current_time, - self._announcment_start_time, - ) if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT @@ -263,6 +271,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- @@ -347,7 +361,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol try: await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) await self._send_tts(announcement.original_media_id, wait_for_tone=False) - await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) except Exception: _LOGGER.exception("Unexpected error while playing announcement") raise @@ -355,6 +372,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Announcement finished") + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 255739c0059..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index d75cbd072e0..0cc0e94e149 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" + _attr_tts_options = {"test-option": "test-value"} + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" self._attr_unique_id = ulid_hex() @@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append((self._extra_system_prompt, start_announcement)) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index c3464beac97..46facb80844 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -64,7 +77,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is entity.device_entry.id - assert kwargs["tts_audio_output"] is None + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -200,24 +213,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 306857a1a44..442f4a62392 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -887,7 +887,8 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -938,7 +939,8 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -981,3 +983,104 @@ async def test_announce_timeout( satellite.transport = Mock() with pytest.raises(TimeoutError): await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task From 55ac0b0f3760b09feee55ee6780de74d947a7d95 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 12:01:39 +1100 Subject: [PATCH 010/178] Fix loading of SMLIGHT integration when no internet is available (#136497) * Don't fail to load integration if internet unavailable * Add test case for no internet * Also test we recover after internet returns --- .../components/smlight/coordinator.py | 16 ++++--- tests/components/smlight/test_init.py | 44 ++++++++++++++++++- tests/components/smlight/test_update.py | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..6be36439e9f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + esp_firmware = None + zb_firmware = None - return SmFwData( - info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware = await self.client.get_firmware_version( info.fw_channel, device=info.model, mode="zigbee" - ), - ) + ) + except SmlightConnectionError as err: + self.async_set_update_error(err) + + return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..d0c5e494ae8 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,41 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = None + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..4fca7369116 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -81,7 +81,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From ff64e5a312e113c77d83a8a6155cbac28ce9e3e6 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Jan 2025 03:57:36 +0100 Subject: [PATCH 011/178] Bump ZHA to 0.0.47 (#136883) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fa8bab409c9..6a42bc986e9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.46"], + "requirements": ["zha==0.0.47"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..a6b56e80d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..4e6d43a6b96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 8babdc0b717a5f6bac127545d602eb8a1873590b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 30 Jan 2025 01:07:55 -0800 Subject: [PATCH 012/178] Bump nest to 7.1.1 (#136888) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index f7e78b2d538..cd961276082 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.0"] + "requirements": ["google-nest-sdm==7.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6b56e80d44..533a77d4981 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e6d43a6b96..4491e64d808 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -883,7 +883,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 0764aca2f13a13f17151bbbf84f4689d9fd31ddc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:31 +0100 Subject: [PATCH 013/178] Poll supervisor job state when creating or restoring a backup (#136891) * Poll supervisor job state when creating or restoring a backup * Update tests * Add tests for create and restore jobs finishing early --- homeassistant/components/hassio/backup.py | 8 +- tests/components/hassio/test_backup.py | 180 ++++++++++++++++------ 2 files changed, 136 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 6b63ab92d5c..b81605264be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -350,8 +350,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id = data.get("reference") backup_complete.set() + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await self._get_job_state(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -506,12 +507,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: - """Handle backup progress.""" + """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 49360783517..f7379b81a14 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -301,6 +301,28 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( type=TEST_BACKUP_5.type, ) +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -813,8 +835,9 @@ async def test_reader_writer_create( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -836,7 +859,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -847,7 +870,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -877,6 +900,66 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( ( @@ -1006,7 +1089,7 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, locations=create_locations, @@ -1018,6 +1101,7 @@ async def test_reader_writer_create_per_agent_encryption( for location in create_locations }, ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -1050,7 +1134,7 @@ async def test_reader_writer_create_per_agent_encryption( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace( @@ -1065,7 +1149,7 @@ async def test_reader_writer_create_per_agent_encryption( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1176,7 +1260,8 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1197,7 +1282,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1206,7 +1291,7 @@ async def test_reader_writer_create_missing_reference_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123"}, + "data": {"done": True, "uuid": TEST_JOB_ID}, }, } ) @@ -1248,8 +1333,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1282,7 +1368,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1291,7 +1377,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1334,8 +1420,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1366,7 +1453,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1375,7 +1462,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1408,8 +1495,9 @@ async def test_reader_writer_create_remote_backup( ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1440,7 +1528,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), @@ -1451,7 +1539,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1510,7 +1598,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,17 +1702,33 @@ async def test_agent_receive_remote_backup( ) +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1657,17 +1761,10 @@ async def test_reader_writer_restore( ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { @@ -1818,21 +1915,9 @@ async def test_restore_progress_after_restart( ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( - name="backup_manager_partial_backup", - reference="1ef41507", - uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), - progress=0.0, - stage="copy_additional_locations", - done=True, - errors=[], - child_jobs=[], - ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) @@ -1860,10 +1945,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) From 5e646a3cb69747b85ebc46f0a8fdd7537902ea5c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:46:27 +0100 Subject: [PATCH 014/178] Add missing discovery string from onewire (#136892) --- homeassistant/components/onewire/config_flow.py | 1 + homeassistant/components/onewire/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index e40e99d0903..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9613a927f8d..8f46369a70b 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", From aed779172d90c55a4435558a4678fff393eeddb8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:07 +0100 Subject: [PATCH 015/178] Ignore dangling symlinks when restoring backup (#136893) --- homeassistant/backup_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 9287aa2bf1b..4d309469017 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -146,6 +146,7 @@ def _extract_backup( config_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*(keep)), + ignore_dangling_symlinks=True, ) elif restore_content.restore_database: for entry in KEEP_DATABASE: From b300fb1fabc6163e62847c71afe6172f52cc48ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 15:25:16 +0100 Subject: [PATCH 016/178] Fix handling of renamed backup files in the core writer (#136898) * Fix handling of renamed backup files in the core writer * Adjust mocking * Raise BackupAgentError instead of KeyError in get_backup_path * Add specific error indicating backup not found * Fix tests * Ensure backups are loaded * Fix tests --- homeassistant/components/backup/agent.py | 13 ++- homeassistant/components/backup/backup.py | 41 ++++++--- homeassistant/components/backup/manager.py | 32 +++---- tests/components/backup/common.py | 17 +++- tests/components/backup/conftest.py | 10 ++- .../backup/snapshots/test_backup.ambr | 33 +++++-- tests/components/backup/test_backup.py | 50 ++++++++--- tests/components/backup/test_http.py | 29 +++--- tests/components/backup/test_manager.py | 90 +++++++++++++++---- 9 files changed, 234 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 33656b6edcc..297ccd6f685 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -27,6 +27,12 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." +class BackupNotFound(BackupAgentError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" + + class BackupAgent(abc.ABC): """Backup agent interface.""" @@ -94,11 +100,16 @@ class LocalBackupAgent(BackupAgent): @abc.abstractmethod def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup. + """Return the local path to an existing backup. The method should return the path to the backup file with the specified id. + Raises BackupAgentError if the backup does not exist. """ + @abc.abstractmethod + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + class BackupAgentPlatformProtocol(Protocol): """Define the format of backup platforms which implement backup agents.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 3f60bd0b88e..c76b50b5935 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup from .util import read_backup @@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): super().__init__() self._hass = hass self._backup_dir = Path(hass.config.path("backups")) - self._backups: dict[str, AgentBackup] = {} + self._backups: dict[str, tuple[AgentBackup, Path]] = {} self._loaded_backups = False async def _load_backups(self) -> None: @@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self._backups = backups self._loaded_backups = True - def _read_backups(self) -> dict[str, AgentBackup]: + def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: """Read backups from disk.""" - backups: dict[str, AgentBackup] = {} + backups: dict[str, tuple[AgentBackup, Path]] = {} for backup_path in self._backup_dir.glob("*.tar"): try: backup = read_backup(backup_path) - backups[backup.backup_id] = backup + backups[backup.backup_id] = (backup, backup_path) except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup)) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" if not self._loaded_backups: await self._load_backups() - return list(self._backups.values()) + return [backup for backup, _ in self._backups.values()] async def async_get_backup( self, @@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - if not (backup := self._backups.get(backup_id)): + if backup_id not in self._backups: return None - backup_path = self.get_backup_path(backup_id) + backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): LOGGER.debug( ( @@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent): return backup def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return self._backup_dir / f"{backup_id}.tar" + """Return the local path to an existing backup. + + Raises BackupAgentError if the backup does not exist. + """ + try: + return self._backups[backup_id][1] + except KeyError as err: + raise BackupNotFound(f"Backup {backup_id} does not exist") from err + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + return self._backup_dir / f"{backup.backup_id}.tar" async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" - if await self.async_get_backup(backup_id) is None: - return + if not self._loaded_backups: + await self._load_backups() - backup_path = self.get_backup_path(backup_id) + try: + backup_path = self.get_backup_path(backup_id) + except BackupNotFound: + return await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fc56505e343..d1f27fa270b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1346,10 +1346,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): if agent_config and not agent_config.protected: password = None + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, + date=date_str, + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=0, + ) + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + local_agent_tar_file_path = local_agent.get_new_backup_path(backup) on_progress( CreateBackupEvent( @@ -1391,19 +1405,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): # ValueError from json_bytes raise BackupReaderWriterError(str(err)) from err else: - backup = AgentBackup( - addons=[], - backup_id=backup_id, - database_included=include_database, - date=date_str, - extra_metadata=extra_metadata, - folders=[], - homeassistant_included=True, - homeassistant_version=HAVERSION, - name=backup_name, - protected=password is not None, - size=size_in_bytes, - ) + backup = replace(backup, size=size_in_bytes) async_add_executor_job = self._hass.async_add_executor_job @@ -1517,7 +1519,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_backup_path(backup.backup_id) + tar_file_path = local_agent.get_new_backup_path(backup) await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 97236ee995d..a7888dbd08c 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + class BackupAgentTest(BackupAgent): """Test backup agent.""" @@ -162,7 +169,13 @@ async def setup_backup_integration( if with_hassio and agent_id == LOCAL_AGENT_ID: continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} + + async def open_stream() -> AsyncIterator[bytes]: + """Open a stream.""" + return aiter_from_iter((b"backup data",)) + + for backup in agent_backups: + await agent.async_upload_backup(open_stream=open_stream, backup=backup) if agent_id == LOCAL_AGENT_ID: agent._loaded_backups = True diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index bef48498ede..d0d9ac7e0e1 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path @@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 032eb7ac537..68b00632a6b 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,7 +32,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ @@ -47,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -82,6 +82,29 @@ 'name': 'Test', 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..ce34c51c105 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -94,11 +108,21 @@ async def test_upload( @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,8 +132,9 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" assert await async_setup_component(hass, DOMAIN, {}) @@ -118,12 +143,13 @@ async def test_delete_backup( path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index ee6803655d5..aac39c04d31 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,7 +1,7 @@ """Tests for the Backup integration.""" import asyncio -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator from io import BytesIO, StringIO import json import tarfile @@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import ( + TEST_BACKUP_ABC123, + BackupAgentTest, + aiter_from_iter, + setup_backup_integration, +) from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -35,6 +40,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found( await setup_backup_integration(hass) client = await hass_client() - with patch( - "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", - return_value=TEST_BACKUP_ABC123, + with ( + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): resp = await client.get( "/api/backup/download/abc123?agent_id=backup.local&password=blah" @@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: - """Convert an iterable to an async iterator.""" - for i in iterable: - yield i - - @patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( download_mock, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5e5b0df74cd..69994028297 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -54,6 +54,8 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, BackupAgentTest, setup_backup_platform, ) @@ -89,6 +91,15 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_service( hass: HomeAssistant, @@ -1311,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" + """Return the local path to an existing backup.""" + return Path("test.tar") + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" return Path("test.tar") @@ -2023,10 +2038,6 @@ async def test_receive_backup_file_write_error( with ( patch("pathlib.Path.open", open_mock), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_BACKUP_ABC123, - ), ): resp = await client.post( "/api/backup/upload?agent_id=test.remote", @@ -2375,18 +2386,61 @@ async def test_receive_backup_file_read_error( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), [ - (LOCAL_AGENT_ID, {}, True, False, "backups"), - (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), - ("test.remote", {}, True, True, "tmp_backups"), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), ], ) async def test_restore_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id: str, + backup_id: str, password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, @@ -2426,14 +2480,14 @@ async def test_restore_backup( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) await ws_client.send_json_auto_id( { "type": "backup/restore", - "backup_id": TEST_BACKUP_ABC123.backup_id, + "backup_id": backup_id, "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, @@ -2473,17 +2527,17 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["success"] is True - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" expected_restore_file = json.dumps( { - "path": backup_path, + "path": full_backup_path, "password": password, "remove_after_restore": agent_id != LOCAL_AGENT_ID, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called @@ -2533,7 +2587,7 @@ async def test_restore_backup_wrong_password( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) @@ -2581,8 +2635,8 @@ async def test_restore_backup_wrong_password( ("parameters", "expected_error", "expected_reason"), [ ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", "backup_manager_error", ), ( @@ -2629,7 +2683,7 @@ async def test_restore_backup_wrong_parameters( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): await ws_client.send_json_auto_id( From fad3d5d29324ed2ef7c2fc28ae1cd99abeaa36ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 17:32:01 +0100 Subject: [PATCH 017/178] Don't blow up when a backup doesn't exist on supervisor (#136907) --- homeassistant/components/hassio/backup.py | 9 +-- tests/components/hassio/test_backup.py | 96 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b81605264be..b9439183d8c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -198,7 +198,10 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorNotFoundError: + return None if self.location not in details.locations: return None return _backup_details_to_agent_backup(details, self.location) @@ -212,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorBadRequestError as err: - if err.args[0] != "Backup does not exist": - raise - _LOGGER.debug("Backup %s does not exist", backup_id) except SupervisorNotFoundError: _LOGGER.debug("Backup %s does not exist", backup_id) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index f7379b81a14..9ba73ade1a3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -544,7 +544,7 @@ async def test_agent_download( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -568,7 +568,7 @@ async def test_agent_download_unavailable_backup( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP_3] @@ -630,6 +630,91 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_agent_delete_backup( hass: HomeAssistant, @@ -666,13 +751,6 @@ async def test_agent_delete_backup( "error": {"code": "unknown_error", "message": "Unknown error"}, }, ), - ( - SupervisorBadRequestError("Backup does not exist"), - { - "success": True, - "result": {"agent_errors": {}}, - }, - ), ( SupervisorNotFoundError(), { From 9e23ff9a4d41ffb0e094646295acb4f49a57ee47 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 16:33:59 +0100 Subject: [PATCH 018/178] Fix onedrive does not fail on delete not found (#136910) * Fix onedrive does not fail on delete not found * Fix onedrive does not fail on delete not found --- homeassistant/components/onedrive/backup.py | 8 ++++++- tests/components/onedrive/test_backup.py | 24 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a5a5c019797..94d60bc6398 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -190,7 +190,13 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - await self._get_backup_file_item(backup_id).delete() + + try: + await self._get_backup_file_item(backup_id).delete() + except APIError as err: + if err.response_status_code == 404: + return + raise @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3d1129377f..3492202d3fe 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -156,6 +156,28 @@ async def test_agents_delete( mock_drive_items.delete.assert_called_once() +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, @@ -257,7 +279,7 @@ async def test_agents_download( ("side_effect", "error"), [ ( - APIError(response_status_code=404, message="File not found."), + APIError(response_status_code=500), "Backup operation failed", ), (TimeoutError(), "Backup operation timed out"), From 613f0add7684b020a7bf86e6b6633083135d36d1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 14:16:51 +0100 Subject: [PATCH 019/178] Convert valve position to int for Shelly BLU TRV (#136912) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 7140c79fbb6..5f0567d034a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -208,7 +208,7 @@ RPC_NUMBERS: Final = { method_params_fn=lambda idx, value: { "id": idx, "method": "Trv.SetPosition", - "params": {"id": 0, "pos": value}, + "params": {"id": 0, "pos": int(value)}, }, removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, From 08bb027eac0eac853753826ec53ac9ca487c04ec Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 09:15:13 -0800 Subject: [PATCH 020/178] Don't log errors when raising a backup exception in Google Drive (#136916) --- homeassistant/components/google_drive/backup.py | 13 ++++--------- tests/components/google_drive/test_backup.py | 6 +++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 4c81f041c8b..73e5902f8f5 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent): try: await self._client.async_upload_backup(open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Upload backup error: %s", err) - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup: {err}") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: return await self._client.async_list_backups() except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("List backups error: %s", err) - raise BackupAgentError("Failed to list backups") from err + raise BackupAgentError(f"Failed to list backups: {err}") from err async def async_get_backup( self, @@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent): stream = await self._client.async_download(file_id) return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Download backup error: %s", err) - raise BackupAgentError("Failed to download backup") from err - _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError(f"Failed to download backup: {err}") from err raise BackupAgentError("Backup not found") async def async_delete_backup( @@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent): await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Delete backup error: %s", err) - raise BackupAgentError("Failed to delete backup") from err + raise BackupAgentError(f"Failed to delete backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 62b7930012c..7e455ebb535 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -141,7 +141,7 @@ async def test_agents_list_backups_fail( assert response["success"] assert response["result"]["backups"] == [] assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" + TEST_AGENT_ID: "Failed to list backups: some error" } @@ -381,7 +381,7 @@ async def test_agents_upload_fail( await hass.async_block_till_done() assert resp.status == 201 - assert "Upload backup error: some error" in caplog.text + assert "Failed to upload backup: some error" in caplog.text async def test_agents_delete( @@ -430,7 +430,7 @@ async def test_agents_delete_fail( assert response["success"] assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} } From b70598673b02f46b71e3699e376b5ffb26824fb0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 18:15:54 +0100 Subject: [PATCH 021/178] Show name of the backup agents in issue (#136925) * Show name of the backup agents in issue * Show name of the backup agents in issue * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/manager.py | 6 +++++- tests/components/backup/test_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index d1f27fa270b..1dbd8f8547d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1166,7 +1166,11 @@ class BackupManager: learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + translation_placeholders={ + "failed_agents": ", ".join( + self.backup_agents[agent_id].name for agent_id in agent_errors + ) + }, ) async def async_can_decrypt_on_download( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 69994028297..4a8d2360d3f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -908,7 +908,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), From f479ed4ff04a0d724bd403c219bcaa5a475fd39d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:39 +0100 Subject: [PATCH 022/178] Fix Sonos importing deprecating constant (#136926) --- homeassistant/components/sonos/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 98bff8d2934..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,7 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -503,7 +507,7 @@ class SonosDiscoveryManager: def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -522,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) From 07acabdb366bc4a79e6c7f045a24f098b3fafa1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:22 +0100 Subject: [PATCH 023/178] Create Xbox signed session in executor (#136927) --- homeassistant/components/xbox/__init__.py | 4 +++- homeassistant/components/xbox/api.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..ab0d510a709 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() From 252b13e63a0e4c1f884c9a264ed950bcbab5dbd7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 17:08:35 +0100 Subject: [PATCH 024/178] Pick onedrive owner from a more reliable source (#136929) * Pick onedrive owner from a more reliable source * fix --- homeassistant/components/onedrive/config_flow.py | 7 +++++-- tests/components/onedrive/conftest.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 83f6dd6e2ee..09c0d1b44cc 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -78,7 +78,7 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive = response.json() + drive: dict = response.json() await self.async_set_unique_id(drive["parentReference"]["driveId"]) @@ -94,7 +94,10 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + user = drive.get("createdBy", {}).get("user", {}).get("displayName") + + title = f"{user}'s OneDrive" if user else "OneDrive" + return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0cca8e9df0b..65142217017 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -88,7 +88,7 @@ def mock_adapter() -> Generator[MagicMock]: status_code=200, json={ "parentReference": {"driveId": "mock_drive_id"}, - "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + "createdBy": {"user": {"displayName": "John Doe"}}, }, ) yield adapter From ad6c3f9e1097903c06e4df62816d5463f1dfa80b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:59:39 +0100 Subject: [PATCH 025/178] Fix backup related translations in Synology DSM (#136931) refernce backup related strings in option-flow strings --- homeassistant/components/synology_dsm/strings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 3d64f908256..d6d40be3fea 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -70,7 +70,13 @@ "data": { "scan_interval": "Minutes between scans", "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" } } } From 74f0af1ba1b01e4163f9367ee0a462dd5209497f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 17:43:48 +0100 Subject: [PATCH 026/178] Fix KeyError for Shelly virtual number component (#136932) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5f0567d034a..c4420783bbb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -186,7 +186,7 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] From 659a0df9abfbf51125a498e6927ac1c6307412d2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 18:38:11 +0100 Subject: [PATCH 027/178] Update frontend to 20250130.0 (#136937) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f4e426485c8..b545026059c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250129.0"] + "requirements": ["home-assistant-frontend==20250130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d9e8f43755..01cfc57f3a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 533a77d4981..2bf3b5f1943 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4491e64d808..6c5f81e6a2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 3847057444bcc192e0a93e3646535c1310b42e9f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 19:28:55 +0100 Subject: [PATCH 028/178] Bump version to 2025.2.0b2 --- 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 77b223fcbcf..271226e92e2 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 = "0b1" +PATCH_VERSION: Final = "0b2" __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 a592b8a194d..2e7b2dfcbc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b1" +version = "2025.2.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9a55b5e3f7622c3949eef07cca83c7d488e7592a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 19:48:47 +0100 Subject: [PATCH 029/178] Ensure Reolink can start when privacy mode is enabled (#136514) * Allow startup when privacy mode is enabled * Add tests * remove duplicate privacy_mode * fix tests * Apply suggestions from code review Co-authored-by: Robert Resch * Store in subfolder and cleanup when removed * Add tests and fixes * fix styling * rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE * use helper store --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 34 +++++++++++++------ .../components/reolink/config_flow.py | 5 ++- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 30 ++++++++++++++-- homeassistant/components/reolink/util.py | 14 ++++++-- tests/components/reolink/conftest.py | 13 ++++++- tests/components/reolink/test_config_flow.py | 11 +++++- tests/components/reolink/test_init.py | 12 +++++++ 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 576ab3c64f8..71ca5428740 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,11 +28,11 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -92,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -248,6 +254,14 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +async def async_remove_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: + """Handle removal of an entry.""" + store = get_store(hass, config_entry.entry_id) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e15a43e360b..7943cadef21 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -37,7 +37,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( + None, "privacy_mode" + ) mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac41..7bd93337c46 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e9b86f1e297..a23f53ff9cd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .util import get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +66,12 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -150,6 +155,14 @@ class ReolinkHost: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: Store[str] | None = None + if self._config_entry_id is not None: + store = get_store(self._hass, self._config_entry_id) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE): + data = await store.async_load() + if data: + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -161,6 +174,19 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_save(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -235,8 +261,6 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) - self.privacy_mode = self._api.baichuan.privacy_mode() - ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e43391f19fb..a5556b66a33 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from reolink_aio.exceptions import ( ApiError, @@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -from .host import ReolinkHost + +if TYPE_CHECKING: + from .host import ReolinkHost + +STORAGE_VERSION = 1 type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] @@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: return config_entry.runtime_data.host +def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: + """Return the reolink store.""" + return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json") + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f8012f91351..2862aa55b4d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" +TEST_PRIVACY = True @pytest.fixture @@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock = host_mock_class.return_value host_mock.get_host_data.return_value = None host_mock.get_states.return_value = None + host_mock.supported.return_value = True host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True @@ -113,6 +119,9 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) # enums host_mock.whiteled_mode.return_value = 1 @@ -128,6 +137,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + yield host_mock_class @@ -158,6 +168,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4d474588f38..4fe671f8cca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -18,7 +18,11 @@ from reolink_aio.exceptions import ( from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -43,6 +47,7 @@ from .conftest import ( TEST_PASSWORD, TEST_PASSWORD2, TEST_PORT, + TEST_PRIVACY, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -82,6 +87,7 @@ async def test_config_flow_manual_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -133,6 +139,7 @@ async def test_config_flow_privacy_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -294,6 +301,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -465,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 7895923dd12..25029375eb6 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -859,3 +859,15 @@ async def test_privacy_mode_change_callback( assert reolink_connect.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON + + +async def test_remove( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test removing of the reolink integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_remove(config_entry.entry_id) From a955901d4025c33a49cf2b53e231d0ff5c85eaa5 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:52:17 +0100 Subject: [PATCH 030/178] Refactor eheimdigital platform async_setup_entry (#136745) --- .../components/eheimdigital/climate.py | 21 +++-- .../components/eheimdigital/coordinator.py | 9 ++- .../components/eheimdigital/light.py | 31 ++++---- .../eheimdigital/snapshots/test_climate.ambr | 76 +++++++++++++++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++ 5 files changed, 148 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 16771ba227d..9b1f825dece 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -2,6 +2,7 @@ from typing import Any +from eheimdigital.device import EheimDigitalDevice from eheimdigital.heater import EheimDigitalHeater from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit @@ -39,17 +40,23 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the climate entities for one or multiple devices.""" + entities: list[EheimDigitalHeaterClimate] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalHeater): + entities.append(EheimDigitalHeaterClimate(coordinator, device)) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalHeater): - async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index f122a1227c5..ee4f09426b7 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice @@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] +type AsyncSetupDeviceEntitiesCallback = Callable[ + [str | dict[str, EheimDigitalDevice]], None +] class EheimDigitalUpdateCoordinator( @@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator( if device_address not in self.known_devices: for platform_callback in self.platform_callbacks: - await platform_callback(device_address) + platform_callback(device_address) async def _async_receive_callback(self) -> None: self.async_set_updated_data(self.hub.devices) diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index a119e0bda8d..5ae0a6e866a 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -3,6 +3,7 @@ from typing import Any from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.device import EheimDigitalDevice from eheimdigital.types import EheimDigitalClientError, LightMode from homeassistant.components.light import ( @@ -37,24 +38,28 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so lights can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" entities: list[EheimDigitalClassicLEDControlLight] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight( + coordinator, device, channel + ) + ) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalClassicLEDControl): - for channel in range(2): - if len(device.tankconfig[channel]) > 0: - entities.append( - EheimDigitalClassicLEDControlLight(coordinator, device, channel) - ) - coordinator.known_devices.add(device.mac_address) async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalClassicLEDControlLight( diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 02d60677b24..d81c59e5af1 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_dynamic_new_devices[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_setup_heater[climate.mock_heater_none-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index 4e770882263..f64b7d7e740 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -56,6 +56,41 @@ async def test_setup_heater( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock} + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.parametrize( ("preset_mode", "heater_mode"), [ From 833b17a8ee0dad73b8916d90fa86b885983c3d66 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Fri, 31 Jan 2025 01:36:06 -0800 Subject: [PATCH 031/178] Bump total-connect-client to 2025.1.4 (#136793) --- .../totalconnect/alarm_control_panel.py | 4 +- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/common.py | 39 +++++++++++-------- .../totalconnect/test_config_flow.py | 20 +++++++--- 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 48ba78acc92..021d1c7b886 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -73,7 +73,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) - self._partition_id = partition_id + self._partition_id = int(partition_id) self._partition = self._location.partitions[partition_id] """ @@ -81,7 +81,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): for most users with new support for partitions. Add _# for partition 2 and beyond. """ - if partition_id == 1: + if int(partition_id) == 1: self._attr_name = None self._attr_unique_id = str(location.location_id) else: diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 33306a7adba..6aff1ea392b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.12"] + "requirements": ["total-connect-client==2025.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bf3b5f1943..dc1bfd1a839 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c5f81e6a2c..98706c45443 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2330,7 +2330,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 828cad71e07..34d451ec0b8 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -49,20 +49,15 @@ USER = { "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", } -RESPONSE_AUTHENTICATE = { +RESPONSE_SESSION_DETAILS = { "ResultCode": ResultCode.SUCCESS.value, - "SessionID": 1, + "ResultData": "Success", + "SessionID": "12345", "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } -RESPONSE_AUTHENTICATE_FAILED = { - "ResultCode": ResultCode.BAD_USER_OR_PASSWORD.value, - "ResultData": "test bad authentication", -} - - PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": ArmingState.DISARMED, @@ -359,13 +354,13 @@ OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} PARTITION_DETAILS_1 = { - "PartitionID": 1, + "PartitionID": "1", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test1", } PARTITION_DETAILS_2 = { - "PartitionID": 2, + "PartitionID": "2", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test2", } @@ -402,6 +397,12 @@ RESPONSE_GET_ZONE_DETAILS_SUCCESS = { TOTALCONNECT_REQUEST = ( "homeassistant.components.totalconnect.TotalConnectClient.request" ) +TOTALCONNECT_GET_CONFIG = ( + "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" +) +TOTALCONNECT_REQUEST_TOKEN = ( + "homeassistant.components.totalconnect.TotalConnectClient._request_token" +) async def setup_platform( @@ -420,7 +421,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -433,6 +434,8 @@ async def setup_platform( TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), ): assert await async_setup_component(hass, DOMAIN, {}) assert mock_request.call_count == 5 @@ -448,17 +451,21 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, RESPONSE_DISARMED, ] - with patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request: + with ( + patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): await hass.config_entries.async_setup(mock_entry.entry_id) assert mock_request.call_count == 5 await hass.async_block_till_done() diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 86419bff817..f5020394bce 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -18,13 +18,15 @@ from homeassistant.data_entry_flow import FlowResultType from .common import ( CONFIG_DATA, CONFIG_DATA_NO_USERCODES, - RESPONSE_AUTHENTICATE, RESPONSE_DISARMED, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_PARTITION_DETAILS, + RESPONSE_SESSION_DETAILS, RESPONSE_SUCCESS, RESPONSE_USER_CODE_INVALID, + TOTALCONNECT_GET_CONFIG, TOTALCONNECT_REQUEST, + TOTALCONNECT_REQUEST_TOKEN, USERNAME, ) @@ -48,7 +50,7 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: """Test user locations form.""" # user/pass provided, so check if valid then ask for usercodes on locations form responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -61,6 +63,8 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -180,7 +184,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_no_locations(hass: HomeAssistant) -> None: """Test with no user locations.""" responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -191,6 +195,8 @@ async def test_no_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -221,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -229,7 +235,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses): + with ( + patch(TOTALCONNECT_REQUEST, side_effect=responses), + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 04a7c6f15e1aea1eda29bf651fe3a3452a9d460f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 10:38:30 +0100 Subject: [PATCH 032/178] Fixes to the user-facing strings of energenie_power_sockets (#136844) --- homeassistant/components/energenie_power_sockets/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index e193b06b25f..4e4e49c68fb 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Searching for Energenie-Power-Sockets Devices.", + "title": "Searching for Energenie Power Sockets devices", "description": "Choose a discovered device.", "data": { "device": "[%key:common::config_flow::data::device%]" @@ -13,7 +13,7 @@ "abort": { "usb_error": "Couldn't access USB devices!", "no_device": "Unable to discover any (new) supported device.", - "device_not_found": "No device was found for the given id.", + "device_not_found": "No device was found for the given ID.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, From 5cec045cac5a0c82bc24093cef940dc08584fbce Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+RunC0deRun@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:29:00 +0100 Subject: [PATCH 033/178] Bump jellyfin-apiclient-python to 1.10.0 (#136872) --- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 19358cff17c..810b9ea45a9 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.9.2"], + "requirements": ["jellyfin-apiclient-python==1.10.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index dc1bfd1a839..84a2cd7ad2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1247,7 +1247,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98706c45443..326670c9f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1058,7 +1058,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest From a74328e60061051f14a18c949c9901e824ef9d5e Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 31 Jan 2025 20:55:42 +1100 Subject: [PATCH 034/178] Suppress color_temp warning if color_temp_kelvin is provided (#136884) --- homeassistant/components/lifx/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 3d37f1c3bc5..8286622e6f3 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if _ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs: # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" From 9cd48dd452a5cafdc6d427803865a773a164eaf3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jan 2025 10:45:01 -0800 Subject: [PATCH 035/178] Persist roborock maps to disk only on shutdown (#136889) * Persist roborock maps to disk only on shutdown * Rename on_unload to on_stop * Spawn 1 executor thread and block writes to disk * Update tests/components/roborock/test_image.py Co-authored-by: Joost Lekkerkerker * Use config entry setup instead of component setup --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/__init__.py | 24 ++++++++++------ .../components/roborock/coordinator.py | 18 ++++++++---- homeassistant/components/roborock/image.py | 10 ++----- .../components/roborock/roborock_storage.py | 20 +++++++++++-- tests/components/roborock/conftest.py | 10 +++++-- tests/components/roborock/test_image.py | 28 +++++++++++-------- tests/components/roborock/test_init.py | 8 ++++++ 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b34dc891d1..b383c1acfd7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -22,7 +22,7 @@ from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -118,13 +118,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - async def on_unload() -> None: - release_tasks = set() - for coordinator in valid_coordinators.values(): - release_tasks.add(coordinator.release()) - await asyncio.gather(*release_tasks) + async def on_stop(_: Any) -> None: + _LOGGER.debug("Shutting down roborock") + await asyncio.gather( + *( + coordinator.async_shutdown() + for coordinator in valid_coordinators.values() + ) + ) - entry.async_on_unload(on_unload) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + on_stop, + ) + ) entry.runtime_data = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,7 +217,7 @@ async def setup_device_v1( try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: - await coordinator.release() + await coordinator.async_shutdown() if isinstance(coordinator.api, RoborockMqttClientV1): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 36333f1c55e..8860a5c1f43 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -116,10 +117,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. - async def release(self) -> None: - """Disconnect from API.""" - await self.api.async_release() - await self.cloud_api.async_release() + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await super().async_shutdown() + await asyncio.gather( + self.map_storage.flush(), + self.api.async_release(), + self.cloud_api.async_release(), + ) async def _update_device_prop(self) -> None: """Update device properties.""" @@ -226,8 +231,9 @@ class RoborockDataUpdateCoordinatorA01( ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: return await self.api.update_values(self.request_protocols) - async def release(self) -> None: - """Disconnect from API.""" + async def async_shutdown(self) -> None: + """Shutdown the coordinator on config entry unload.""" + await super().async_shutdown() await self.api.async_release() @cached_property diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b0de4f9caa5..b4776c27164 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -157,13 +157,9 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if self.cached_map != content: self.cached_map = content - self.config_entry.async_create_task( - self.hass, - self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ), - f"{self.unique_id} map", + await self.coordinator.map_storage.async_save_map( + self.map_flag, + content, ) return self.cached_map diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py index 62e15e889be..8a469b0a38e 100644 --- a/homeassistant/components/roborock/roborock_storage.py +++ b/homeassistant/components/roborock/roborock_storage.py @@ -31,6 +31,7 @@ class RoborockMapStorage: self._path_prefix = ( _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug ) + self._write_queue: dict[int, bytes] = {} async def async_load_map(self, map_flag: int) -> bytes | None: """Load maps from disk.""" @@ -48,9 +49,22 @@ class RoborockMapStorage: return None async def async_save_map(self, map_flag: int, content: bytes) -> None: - """Write map if it should be updated.""" - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - await self._hass.async_add_executor_job(self._save_map, filename, content) + """Save the map to a pending write queue.""" + self._write_queue[map_flag] = content + + async def flush(self) -> None: + """Flush all maps to disk.""" + _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) + + queue = self._write_queue.copy() + + def _flush_all() -> None: + for map_flag, content in queue.items(): + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + self._save_map(filename, content) + + await self._hass.async_add_executor_job(_flush_all) + self._write_queue.clear() def _save_map(self, filename: Path, content: bytes) -> None: """Write the map to disk.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index e5fc5cb7eb6..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -19,9 +19,9 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .mock_data import ( BASE_URL, @@ -207,13 +207,13 @@ async def setup_entry( ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" with patch("homeassistant.components.roborock.PLATFORMS", platforms): - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() yield mock_roborock_entry @pytest.fixture -def cleanup_map_storage( +async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" @@ -225,4 +225,8 @@ def cleanup_map_storage( pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id ) yield storage_path + # We need to first unload the config entry because unloading it will + # persist any unsaved maps to storage. + if mock_roborock_entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 90886f25929..fd6c8b2796a 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -12,6 +12,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -120,7 +121,7 @@ async def test_load_stored_image( MAP_DATA.image.data.save(img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() - # Load the image on demand, which should ensure it is cached on disk + # Load the image on demand, which should queue it to be cached on disk client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK @@ -151,22 +152,25 @@ async def test_fail_to_save_image( caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" - # Reload the config entry so that the map is saved in storage and entities exist. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + with patch( "homeassistant.components.roborock.roborock_storage.Path.write_bytes", side_effect=OSError, ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + assert "Unable to write map file" in caplog.text - # Ensure that map is still working properly. - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - - assert "Unable to write map file" in caplog.text + # Config entry is unloaded successfully + assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED async def test_fail_to_load_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index efd1c3f66f4..904a3af89d6 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -183,6 +183,10 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + assert not cleanup_map_storage.exists() + + # Flush to disk + await hass.config_entries.async_unload(setup_entry.entry_id) assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories @@ -209,6 +213,10 @@ async def test_oserror_remove_image( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + # Image content is saved when unloading + assert not cleanup_map_storage.exists() + await hass.config_entries.async_unload(setup_entry.entry_id) + assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories From c9fd27555c477ff1028c8dc5e6e04d815f4bfb7e Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 22:59:03 -0800 Subject: [PATCH 036/178] Include the redirect URL in the Google Drive instructions (#136906) * Include the redirect URL in the Google Drive instructions * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../google_drive/application_credentials.py | 2 ++ .../components/google_drive/strings.json | 2 +- .../helpers/config_entry_oauth2_flow.py | 26 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index c2f59b298cb..1c4421623d4 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,6 +2,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), } diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 3441bec4294..e6658fb08e9 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -35,6 +35,6 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." } } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c2a61335769..24a9de5b562 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +@callback +def async_get_redirect_uri(hass: HomeAssistant) -> str: + """Return the redirect uri.""" + if "my" in hass.config.components: + return MY_AUTH_CALLBACK_PATH + + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + + if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + return f"{ha_host}{AUTH_CALLBACK_PATH}" + + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def redirect_uri(self) -> str: """Return the redirect uri.""" - if "my" in self.hass.config.components: - return MY_AUTH_CALLBACK_PATH - - if (req := http.current_request.get()) is None: - raise RuntimeError("No current request in context") - - if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: - raise RuntimeError("No header in request") - - return f"{ha_host}{AUTH_CALLBACK_PATH}" + return async_get_redirect_uri(self.hass) @property def extra_authorize_data(self) -> dict: From a391f0a7cc9ee7d745bac5f35aae42cf414ea3c7 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 23:33:58 -0800 Subject: [PATCH 037/178] Bump opower to 0.8.9 (#136911) * Bump opower to 0.8.9 * mypy --- homeassistant/components/opower/coordinator.py | 14 ++++++-------- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/sensor.py | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index f6f3524d630..6957ae4984c 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,18 +5,16 @@ import logging from types import MappingProxyType from typing import Any, cast -import aiohttp from opower import ( Account, AggregateType, - CannotConnect, CostRead, Forecast, - InvalidAuth, MeterType, Opower, ReadResolution, ) +from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -89,7 +87,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise UpdateFailed(f"Error during login: {err}") from err try: forecasts: list[Forecast] = await self.api.async_get_forecast() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting forecasts: %s", err) raise _LOGGER.debug("Updating sensor data with: %s", forecasts) @@ -102,7 +100,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Insert Opower statistics.""" try: accounts = await self.api.async_get_accounts() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: @@ -271,7 +269,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_reads = await self.api.async_get_cost_reads( account, AggregateType.BILL, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting monthly cost reads: %s", err) raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) @@ -290,7 +288,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): daily_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.DAY, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting daily cost reads: %s", err) raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) @@ -308,7 +306,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): hourly_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.HOUR, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting hourly cost reads: %s", err) raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7227f7171ac..d168cba5752 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.8"] + "requirements": ["opower==0.8.9"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 7f8eb22d1e6..f9d0fe62332 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -97,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="elec_end_date", @@ -105,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -169,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="gas_end_date", @@ -177,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 84a2cd7ad2e..2d1d4b1cca9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1592,7 +1592,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 326670c9f63..d699ae56a97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1328,7 +1328,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 From 6e55ba137add6061b6ed056aeeb0a24498553ebb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 17:33:30 +0100 Subject: [PATCH 038/178] Make backup file names more user friendly (#136928) * Make backup file names more user friendly * Strip backup name * Strip backup name * Underscores --- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/util.py | 9 ++ homeassistant/components/backup/websocket.py | 2 +- tests/components/backup/test_backup.py | 4 +- tests/components/backup/test_manager.py | 139 +++++++++++++++++-- tests/components/backup/test_util.py | 28 ++++ 7 files changed, 171 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c76b50b5935..b6282186c06 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup -from .util import read_backup +from .util import read_backup, suggested_filename async def async_get_backup_agents( @@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): def get_new_backup_path(self, backup: AgentBackup) -> Path: """Return the local path to a new backup.""" - return self._backup_dir / f"{backup.backup_id}.tar" + return self._backup_dir / suggested_filename(backup) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1dbd8f8547d..2576eb8d1f0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -898,7 +898,7 @@ class BackupManager: ) backup_name = ( - name + (name if name is None else name.strip()) or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) extra_metadata = extra_metadata or {} diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 2416aa5f28e..e9d597aa709 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.thread import ThreadWithException @@ -117,6 +118,14 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename(backup: AgentBackup) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(backup.date, raise_on_error=True) + return "_".join( + f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() + ) + + def validate_password(path: Path, password: str | None) -> bool: """Validate the password.""" with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index feb762bb50b..93dd81c3c14 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_database", default=True): bool, vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, - vol.Optional("name"): str, + vol.Optional("name"): vol.Any(str, None), vol.Optional("password"): vol.Any(str, None), } ) diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index ce34c51c105..c441cae292c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,7 +103,9 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + assert ( + move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" + ) @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4a8d2360d3f..b98cec47e8d 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -21,6 +21,7 @@ from unittest.mock import ( patch, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -236,6 +237,64 @@ async def test_create_backup_service( "password": None, }, ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": "user defined name", + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "user defined name", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": " ", # Name which is just whitespace + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), ], ) async def test_async_create_backup( @@ -345,18 +404,70 @@ async def test_create_backup_wrong_parameters( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + ( + "agent_ids", + "backup_directory", + "name", + "expected_name", + "expected_filename", + "temp_file_unlink_call_count", + ), [ - ([LOCAL_AGENT_ID], "backups", 0), - (["test.remote"], "tmp_backups", 1), - ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ( + [LOCAL_AGENT_ID], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + None, + "Custom backup 2025.1.0", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + [LOCAL_AGENT_ID], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + "custom_name", + "custom_name", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), ], ) @pytest.mark.parametrize( "params", [ {}, - {"include_database": True, "name": "abc123"}, + {"include_database": True}, {"include_database": False}, {"password": "pass123"}, ], @@ -364,6 +475,7 @@ async def test_create_backup_wrong_parameters( async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -371,6 +483,9 @@ async def test_initiate_backup( params: dict[str, Any], agent_ids: list[str], backup_directory: str, + name: str | None, + expected_name: str, + expected_filename: str, temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -393,9 +508,9 @@ async def test_initiate_backup( ) ws_client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") include_database = params.get("include_database", True) - name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -427,7 +542,7 @@ async def test_initiate_backup( patch("pathlib.Path.unlink") as unlink_mock, ): await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": agent_ids} | params + {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params ) result = await ws_client.receive_json() assert result["event"] == { @@ -487,7 +602,7 @@ async def test_initiate_backup( "exclude_database": not include_database, "version": "2025.1.0", }, - "name": name, + "name": expected_name, "protected": bool(password), "slug": backup_id, "type": "partial", @@ -514,7 +629,7 @@ async def test_initiate_backup( "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": name, + "name": expected_name, "with_automatic_settings": False, } @@ -528,7 +643,7 @@ async def test_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_id}.tar" + assert tar_file_path == f"{backup_directory}/{expected_filename}" @pytest.mark.usefixtures("mock_backup_generation") @@ -1482,7 +1597,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1491,7 +1606,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index db759805c8f..3bcb53f7c86 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -15,6 +15,7 @@ from homeassistant.components.backup.util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, read_backup, + suggested_filename, validate_password, ) from homeassistant.core import HomeAssistant @@ -384,3 +385,30 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: # padding. await encryptor.wait() assert isinstance(encryptor._workers[0].error, tarfile.TarError) + + +@pytest.mark.parametrize( + ("name", "resulting_filename"), + [ + ("test", "test_-_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ], +) +def test_suggested_filename(name: str, resulting_filename: str) -> None: + """Test suggesting a filename.""" + backup = AgentBackup( + addons=[], + backup_id="1234", + date="2025-01-30 13:42:12.345678-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name=name, + protected=False, + size=1234, + ) + assert suggested_filename(backup) == resulting_filename From eca30717a95f3719c889f8272dd5f94542ad47d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 12:55:14 -0600 Subject: [PATCH 039/178] Bump zeroconf to 0.142.0 (#136940) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.141.0...0.142.0 --- 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 6fe2b5b1923..be6f2d111d7 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.141.0"] + "requirements": ["zeroconf==0.142.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 01cfc57f3a8..a15e1bb61be 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.141.0 +zeroconf==0.142.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 2e7b2dfcbc1..fb8545681e8 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.141.0" + "zeroconf==0.142.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index a98d53b6037..412252a0846 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.141.0 +zeroconf==0.142.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2d1d4b1cca9..00702b6914f 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.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d699ae56a97..325c01b0708 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.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From eb344ba3359f059f1743ff2787679bfe7aaa2e98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 13:38:27 -0600 Subject: [PATCH 040/178] Bump aiohttp-asyncmdnsresolver to 0.0.2 (#136942) --- 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 a15e1bb61be..891d91e134b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index fb8545681e8..e3bee8e6608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", + "aiohttp-asyncmdnsresolver==0.0.2", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 412252a0846..77fd3887db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From 71a40d9234d98fa6487290de5ee42d22b5282146 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 Jan 2025 21:59:00 +0100 Subject: [PATCH 041/178] Update knx-frontend to 2025.1.30.194235 (#136954) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f34ce0f4589..86c050443e3 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.28.225404" + "knx-frontend==2025.1.30.194235" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 00702b6914f..348c6e81aa4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 325c01b0708..d90fa84e2a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 From ad86f9efd5ea15ecf61462ef0965f411494e31b4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jan 2025 16:01:24 -0600 Subject: [PATCH 042/178] Consume extra system prompt in first pipeline (#136958) --- homeassistant/components/assist_satellite/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 927229c9756..0229e0358b1 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -264,7 +264,6 @@ class AssistSatelliteEntity(entity.Entity): await self.async_start_conversation(announcement) finally: self._is_announcing = False - self._extra_system_prompt = None async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -282,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity): """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() + # Consume system prompt in first pipeline + extra_system_prompt = self._extra_system_prompt + self._extra_system_prompt = None + if self._wake_word_intercept_future and start_stage in ( PipelineStage.WAKE_WORD, PipelineStage.STT, @@ -358,7 +361,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, - conversation_extra_system_prompt=self._extra_system_prompt, + conversation_extra_system_prompt=extra_system_prompt, ), f"{self.entity_id}_pipeline", ) From c77bca1e4417faea5914d02f54a8600906f914c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 01:34:39 -0600 Subject: [PATCH 043/178] Bump habluetooth to 3.15.0 (#136973) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1fcd507da83..38677400418 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.14.0" + "habluetooth==3.15.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 891d91e134b..64353901fbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.14.0 +habluetooth==3.15.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 348c6e81aa4..b63d203b0e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d90fa84e2a3..573ed230cb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 26ae498974e018b18dcb8db6f0b29eb5e4059d74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 19:33:48 +0100 Subject: [PATCH 044/178] Delete old addon update backups when updating addon (#136977) * Delete old addon update backups when updating addon * Address review comments * Add tests --- homeassistant/components/backup/config.py | 77 ++-------- homeassistant/components/backup/manager.py | 64 +++++++++ homeassistant/components/hassio/backup.py | 23 ++- tests/components/hassio/test_update.py | 129 ++++++++++++++++- tests/components/hassio/test_websocket_api.py | 133 +++++++++++++++++- 5 files changed, 350 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0baefe1f52d..4d0cd82bc44 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -252,7 +250,7 @@ class RetentionConfig: """Delete backups older than days.""" self._schedule_next(manager) - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return backups older than days to delete.""" @@ -269,7 +267,9 @@ class RetentionConfig: < now } - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) manager.remove_next_delete_event = async_call_later( manager.hass, timedelta(days=1), _delete_backups @@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False): password: str | None -async def _delete_filtered_backups( - manager: BackupManager, - backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], -) -> None: - """Delete backups parsed with a filter. - - :param manager: The backup manager. - :param backup_filter: A filter that should return the backups to delete. - """ - backups, get_agent_errors = await manager.async_get_backups() - if get_agent_errors: - LOGGER.debug( - "Error getting backups; continuing anyway: %s", - get_agent_errors, - ) - - # only delete backups that are created with the saved automatic settings - backups = { +def _automatic_backups_filter( + backups: dict[str, ManagerBackup], +) -> dict[str, ManagerBackup]: + """Return automatic backups.""" + return { backup_id: backup for backup_id, backup in backups.items() if backup.with_automatic_settings } - LOGGER.debug("Total automatic backups: %s", backups) - - filtered_backups = backup_filter(backups) - - if not filtered_backups: - return - - # always delete oldest backup first - filtered_backups = dict( - sorted( - filtered_backups.items(), - key=lambda backup_item: backup_item[1].date, - ) - ) - - if len(filtered_backups) >= len(backups): - # Never delete the last backup. - last_backup = filtered_backups.popitem() - LOGGER.debug("Keeping the last backup: %s", last_backup) - - LOGGER.debug("Backups to delete: %s", filtered_backups) - - if not filtered_backups: - return - - backup_ids = list(filtered_backups) - delete_results = await asyncio.gather( - *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) - ) - agent_errors = { - backup_id: error - for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error - } - if agent_errors: - LOGGER.error( - "Error deleting old copies: %s", - agent_errors, - ) - async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: """Delete backups exceeding the configured retention count.""" - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" @@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N )[: max(len(backups) - manager.config.data.retention.copies, 0)] ) - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 2576eb8d1f0..42b5f522ecd 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -685,6 +685,70 @@ class BackupManager: return agent_errors + async def async_delete_filtered_backups( + self, + *, + include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + ) -> None: + """Delete backups parsed with a filter. + + :param include_filter: A filter that should return the backups to consider for + deletion. Note: The newest of the backups returned by include_filter will + unconditionally be kept, even if delete_filter returns all backups. + :param delete_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await self.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # Run the include filter first to ensure we only consider backups that + # should be included in the deletion process. + backups = include_filter(backups) + + LOGGER.debug("Total automatic backups: %s", backups) + + backups_to_delete = delete_filter(backups) + + if not backups_to_delete: + return + + # always delete oldest backup first + backups_to_delete = dict( + sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(backups_to_delete) >= len(backups): + # Never delete the last backup. + last_backup = backups_to_delete.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", backups_to_delete) + + if not backups_to_delete: + return + + backup_ids = list(backups_to_delete) + delete_results = await asyncio.gather( + *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) + async def async_receive_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b9439183d8c..59242a32708 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( Folder, IdleEvent, IncorrectPasswordError, + ManagerBackup, NewBackup, RestoreBackupEvent, RestoreBackupState, @@ -51,6 +52,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" +# Set on backups automatically created when updating an addon +TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) @@ -614,10 +617,20 @@ async def backup_addon_before_update( else: password = None + def addon_update_backup_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return addon update backups.""" + return { + backup_id: backup + for backup_id, backup in backups.items() + if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon + } + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], - extra_metadata={"supervisor.addon_update": addon}, + extra_metadata={TAG_ADDON_UPDATE: addon}, include_addons=[addon], include_all_addons=False, include_database=False, @@ -628,6 +641,14 @@ async def backup_addon_before_update( ) except BackupManagerError as err: raise HomeAssistantError(f"Error creating backup: {err}") from err + else: + try: + await backup_manager.async_delete_filtered_backups( + include_filter=addon_update_backup_filter, + delete_filter=lambda backups: backups, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error deleting old backups: {err}") from err async def backup_core_before_update(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 62fe49c5f23..332f2050cf2 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -3,13 +3,13 @@ from datetime import timedelta import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -338,6 +338,113 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -550,9 +657,19 @@ async def test_update_addon_with_error( ) +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, r"^Error creating backup: "), + (None, BackupManagerError, r"^Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon update entity with error.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -573,9 +690,13 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, ), - pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + pytest.raises(HomeAssistantError, match=message), ): assert not await hass.services.async_call( "update", diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index ab8dc1475e2..bcac19e0fa3 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -2,13 +2,13 @@ import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -457,6 +457,114 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_core( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -622,10 +730,20 @@ async def test_update_addon_with_error( } +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, "Error creating backup: "), + (None, BackupManagerError, "Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon with backup and error.""" client = await hass_ws_client(hass) @@ -647,7 +765,11 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, ), ): await client.send_json_auto_id( @@ -655,10 +777,7 @@ async def test_update_addon_with_backup_and_error( ) result = await client.receive_json() assert not result["success"] - assert result["error"] == { - "code": "home_assistant_error", - "message": "Error creating backup: ", - } + assert result["error"] == {"code": "home_assistant_error", "message": message} async def test_update_core_with_error( From 0272d37e88a852a7da1420907e440338f9cb5d68 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 11:23:33 +0100 Subject: [PATCH 045/178] Retry backup uploads in onedrive (#136980) * Retry backup uploads in onedrive * no exponential backup on timeout --- homeassistant/components/onedrive/backup.py | 34 ++++- tests/components/onedrive/conftest.py | 7 + tests/components/onedrive/test_backup.py | 138 +++++++++++++++++++- 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 94d60bc6398..7f4bd5a0738 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html @@ -9,7 +10,7 @@ import json import logging from typing import Any, Concatenate, cast -from httpx import Response +from httpx import Response, TimeoutException from kiota_abstractions.api_error import APIError from kiota_abstractions.authentication import AnonymousAuthenticationProvider from kiota_abstractions.headers_collection import HeadersCollection @@ -42,6 +43,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB +MAX_RETRIES = 5 async def async_get_backup_agents( @@ -96,7 +98,7 @@ def handle_backup_errors[_R, **P]( ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutError as err: + except TimeoutException as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -268,6 +270,7 @@ class OneDriveBackupAgent(BackupAgent): start = 0 buffer: list[bytes] = [] buffer_size = 0 + retries = 0 async for chunk in stream: buffer.append(chunk) @@ -279,11 +282,28 @@ class OneDriveBackupAgent(BackupAgent): buffer_size > UPLOAD_CHUNK_SIZE ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) + try: + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + except APIError as err: + if ( + err.response_status_code and err.response_status_code < 500 + ): # no retry on 4xx errors + raise + if retries < MAX_RETRIES: + await asyncio.sleep(2**retries) + retries += 1 + continue + raise + except TimeoutException: + if retries < MAX_RETRIES: + retries += 1 + continue + raise + retries = 0 start += UPLOAD_CHUNK_SIZE uploaded_chunks += 1 buffer_size -= UPLOAD_CHUNK_SIZE diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 65142217017..649966a7828 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -176,3 +176,10 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield + + +@pytest.fixture(autouse=True) +def mock_asyncio_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): + yield diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 3492202d3fe..162ecb7d92a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -8,8 +8,10 @@ from io import StringIO from json import dumps from unittest.mock import Mock, patch +from httpx import TimeoutException from kiota_abstractions.api_error import APIError from msgraph.generated.models.drive_item import DriveItem +from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -255,6 +257,140 @@ async def test_broken_upload_session( assert "Failed to start backup upload" in caplog.text +@pytest.mark.parametrize( + "side_effect", + [ + APIError(response_status_code=500), + TimeoutException("Timeout"), + ], +) +async def test_agents_upload_errors_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = [ + side_effect, + LargeFileUploadSession(next_expected_ranges=["2-"]), + LargeFileUploadSession(next_expected_ranges=["2-"]), + ] + + 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.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 3 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.patch.assert_called_once() + + +async def test_agents_upload_4xx_errors_not_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = APIError(response_status_code=404) + + 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.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 1 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert "Backup operation failed" in caplog.text + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIError(response_status_code=500), "Backup operation failed"), + (TimeoutException("Timeout"), "Backup operation timed out"), + ], +) +async def test_agents_upload_fails_after_max_retries( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, + error: str, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.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, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 6 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert error in caplog.text + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_drive_items: MagicMock, @@ -282,7 +418,7 @@ async def test_agents_download( APIError(response_status_code=500), "Backup operation failed", ), - (TimeoutError(), "Backup operation timed out"), + (TimeoutException("Timeout"), "Backup operation timed out"), ], ) async def test_delete_error( From 6bab5b2c320737b35ca22a3f268a3c00052ad55b Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 12:10:44 +0100 Subject: [PATCH 046/178] Fix missing duration translation for Swiss public transport integration (#136982) --- .../swiss_public_transport/icons.json | 2 +- .../swiss_public_transport/sensor.py | 2 + .../swiss_public_transport/strings.json | 4 +- .../snapshots/test_sensor.ambr | 101 +++++++++--------- .../swiss_public_transport/test_sensor.py | 2 +- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json index 06a640a06b2..45cf4713705 100644 --- a/homeassistant/components/swiss_public_transport/icons.json +++ b/homeassistant/components/swiss_public_transport/icons.json @@ -10,7 +10,7 @@ "departure2": { "default": "mdi:bus-clock" }, - "duration": { + "trip_duration": { "default": "mdi:timeline-clock" }, "transfers": { diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a0131938a37..c8075a6746c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -56,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( ], SwissPublicTransportSensorEntityDescription( key="duration", + translation_key="trip_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data_connection: data_connection["duration"], ), SwissPublicTransportSensorEntityDescription( diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index ef8cc5595e3..270cb097e0a 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -64,8 +64,8 @@ "departure2": { "name": "Departure +2" }, - "duration": { - "name": "Duration" + "trip_duration": { + "name": "Trip duration" }, "transfers": { "name": "Transfers" diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index dbd689fc8f6..b8ad82c7b79 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -192,55 +192,6 @@ 'state': '2024-01-06T17:05:00+00:00', }) # --- -# name: test_all_entities[sensor.zurich_bern_duration-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.zurich_bern_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'swiss_public_transport', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Zürich Bern_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.zurich_bern_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by transport.opendata.ch', - 'device_class': 'duration', - 'friendly_name': 'Zürich Bern Duration', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zurich_bern_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }) -# --- # name: test_all_entities[sensor.zurich_bern_line-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,3 +333,55 @@ 'state': '0', }) # --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trip_duration', + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Trip duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 4afdd88c9de..6e832728277 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -83,7 +83,7 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" From 00298db465eef687dc14f728e1cd157a15096aeb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 13:39:59 +0100 Subject: [PATCH 047/178] Call backup listener during setup in onedrive (#136990) --- homeassistant/components/onedrive/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 7419ca6e20c..4ae5ac73560 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder_id, ) + _async_notify_backup_listeners_soon(hass) + return True From c28d465f3b8d8c4296e5131f0418e7ef321c8f8b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:49:25 +0100 Subject: [PATCH 048/178] Bumb python-homewizard-energy to 8.3.2 (#136995) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 957ed912b7d..51a315b2286 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.3.0"], + "requirements": ["python-homewizard-energy==v8.3.2"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b63d203b0e0..50994859d2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2391,7 +2391,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 573ed230cb5..8a2b74c5ce9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.izone python-izone==1.2.9 From 07b85163d522d3b69d2a4b37db91d430aac104c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 16:32:11 +0100 Subject: [PATCH 049/178] Use device name as entity name in Eheim digital climate (#136997) --- .../components/eheimdigital/climate.py | 1 + .../eheimdigital/snapshots/test_climate.ambr | 20 +++++++++---------- tests/components/eheimdigital/test_climate.py | 16 +++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 9b1f825dece..7ad06659089 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -76,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_mode = PRESET_NONE _attr_translation_key = "heater" + _attr_name = None def __init__( self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index d81c59e5af1..171d3d427fc 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_dynamic_new_devices[climate.mock_heater_none-entry] +# name: test_dynamic_new_devices[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,7 +24,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_dynamic_new_devices[climate.mock_heater_none-state] +# name: test_dynamic_new_devices[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -68,14 +68,14 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'auto', }) # --- -# name: test_setup_heater[climate.mock_heater_none-entry] +# name: test_setup_heater[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -100,7 +100,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,11 +121,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_heater[climate.mock_heater_none-state] +# name: test_setup_heater[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -144,7 +144,7 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f64b7d7e740..f1f29ce9d34 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -123,7 +123,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -132,7 +132,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -161,7 +161,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -170,7 +170,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -204,7 +204,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -213,7 +213,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -239,7 +239,7 @@ async def test_state_update( ) await hass.async_block_till_done() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.attributes["hvac_action"] == HVACAction.IDLE assert state.attributes["preset_mode"] == HEATER_BIO_MODE @@ -249,6 +249,6 @@ async def test_state_update( await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.state == HVACMode.OFF assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 3107b813337cbe717f873b7eb292d92f368d4d3a Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 13:19:04 +0100 Subject: [PATCH 050/178] Remove the unparsed config flow error from Swiss public transport (#136998) --- homeassistant/components/swiss_public_transport/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 270cb097e0a..64817f89f42 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", "title": "Swiss Public Transport" }, "time_fixed": { From f4166c53909989e8efe60b5e6e72274c5f6e1e0d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 16:50:30 +0100 Subject: [PATCH 051/178] Make sure we load the backup integration before frontend (#137010) --- homeassistant/bootstrap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d89a9595868..8c27f41aabe 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -161,6 +161,10 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", + # Backup is an after dependency of frontend, after dependencies + # are not promoted from stage 2 to earlier stages, so we need to + # add it here. + "backup", } RECORDER_INTEGRATIONS = { # Setup after frontend From 4fe76ec78ce6e7781ab119fd29e6df00fddfcd8a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 17:26:43 +0100 Subject: [PATCH 052/178] Revert previous PR and remove URL from error message instead (#137018) --- homeassistant/components/swiss_public_transport/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 64817f89f42..1cdbd527467 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid", "too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.", "unknown": "An unknown error was raised by python-opendata-transport" }, @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", + "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" }, "time_fixed": { From b412164440d5797be059fc45d7a0453c3b012d20 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 18:20:30 +0100 Subject: [PATCH 053/178] Make supervisor backup file names more user friendly (#137020) --- homeassistant/components/backup/__init__.py | 3 +++ homeassistant/components/backup/util.py | 11 +++++++---- homeassistant/components/hassio/backup.py | 17 ++++++++++++++--- tests/components/hassio/test_backup.py | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3003f94c2ed..86e5b95d196 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -35,6 +35,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder +from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers __all__ = [ @@ -58,6 +59,8 @@ __all__ = [ "RestoreBackupState", "WrittenBackup", "async_get_manager", + "suggested_filename", + "suggested_filename_from_name_date", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e9d597aa709..fbb13b4721a 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -118,12 +118,15 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename_from_name_date(name: str, date_str: str) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(date_str, raise_on_error=True) + return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + + def suggested_filename(backup: AgentBackup) -> str: """Suggest a filename for the backup.""" - date = dt_util.parse_datetime(backup.date, raise_on_error=True) - return "_".join( - f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() - ) + return suggested_filename_from_name_date(backup.name, backup.date) def validate_password(path: Path, password: str | None) -> bool: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 59242a32708..495e953df9d 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging import os -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, cast from uuid import UUID @@ -39,11 +39,14 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, + suggested_filename as suggested_backup_filename, + suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -116,12 +119,15 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + extra_metadata = details.extra or {} location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, database_included=database_included, - date=details.date.isoformat(), + date=extra_metadata.get( + "supervisor.backup_request_date", details.date.isoformat() + ), extra_metadata=details.extra or {}, folders=[Folder(folder) for folder in details.folders], homeassistant_included=homeassistant_included, @@ -177,7 +183,8 @@ class SupervisorBackupAgent(BackupAgent): return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( - location={self.location} + location={self.location}, + filename=PurePath(suggested_backup_filename(backup)), ) await self._client.backups.upload_backup( stream, @@ -304,6 +311,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): locations = [] locations = locations or [LOCATION_CLOUD_BACKUP] + date = dt_util.now().isoformat() + extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} + filename = suggested_filename_from_name_date(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( @@ -317,6 +327,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, + filename=PurePath(filename), ) ) except SupervisorError as err: diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9ba73ade1a3..d001a358640 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -11,6 +11,7 @@ from dataclasses import replace from datetime import datetime from io import StringIO import os +from pathlib import PurePath from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -26,6 +27,7 @@ from aiohasupervisor.models import ( mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -854,8 +856,10 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( compressed=True, extra={ "instance_id": ANY, + "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, + filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -907,12 +911,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( async def test_reader_writer_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -982,10 +988,12 @@ async def test_reader_writer_create( async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1140,6 +1148,7 @@ async def test_reader_writer_create_job_done( async def test_reader_writer_create_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, commands: dict[str, Any], password: str | None, @@ -1151,6 +1160,7 @@ async def test_reader_writer_create_per_agent_encryption( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") mounts = MountsInfo( default_backup_mount=None, mounts=[ @@ -1170,6 +1180,7 @@ async def test_reader_writer_create_per_agent_encryption( supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, + extra=DEFAULT_BACKUP_OPTIONS.extra, locations=create_locations, location_attributes={ location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( @@ -1254,6 +1265,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: + assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations @@ -1569,10 +1581,12 @@ async def test_reader_writer_create_info_error( async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE From e86a633c23ebc4a7e28e4f5090b189bab51ac321 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 11:29:44 -0600 Subject: [PATCH 054/178] Bump habluetooth to 3.17.0 (#137022) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 38677400418..d6ed9281099 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.15.0" + "habluetooth==3.17.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64353901fbf..a7fbe090f23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.15.0 +habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 50994859d2f..c955d01ac48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a2b74c5ce9..1eef877f6c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From ae79b0940140ab1ca99afb0191011027bf5b94e0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Jan 2025 19:25:24 +0100 Subject: [PATCH 055/178] Update frontend to 20250131.0 (#137024) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b545026059c..2ecb165554a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250130.0"] + "requirements": ["home-assistant-frontend==20250131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7fbe090f23..2d4e92e2e9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c955d01ac48..e2f5a70d8b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1eef877f6c5..eb4cba20f67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From ca2a555037d7dc5dada5096f30a45ffb8275c978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:15:31 -0600 Subject: [PATCH 056/178] Bump bleak-esphome to 2.6.0 (#137025) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bab62723c82..3a55730c60f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ecc7afb3661..9585be72c63 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.2.0" + "bleak-esphome==2.6.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e2f5a70d8b3..660e0a0bc35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb4cba20f67..c6b315d85aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 7deb1715ddf0d8959e62100fe0b279f09e933306 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:52:38 -0600 Subject: [PATCH 057/178] Bump SQLAlchemy to 2.0.37 (#137028) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.37 There is a bug fix that likely affects us that could lead to corrupted queries https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-e4d04d8eb1bccee16b74f5662aff8edd --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/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, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index d3b6e52ad11..7cef284ef60 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 01c95d6c5e4..0094770d53b 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.37", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2d4e92e2e9a..0a1b97abc55 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index e3bee8e6608..74d634ea1a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.1.4", - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 77fd3887db4..02f3849148b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 660e0a0bc35..cc5ed9ee62d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6b315d85aa..d0797b8f4a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From 5450ed8445af41857160c616730ba8b078ee3864 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 31 Jan 2025 20:17:14 +0100 Subject: [PATCH 058/178] Bump deebot-client to 11.1.0b2 (#137030) --- 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 188f59f74e4..16929e1741a 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==11.1.0b1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc5ed9ee62d..f321be6254f 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==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0797b8f4a6..28f181530a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns From e1105ef2fa224fc24742178834804be2b43c5d73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2025 19:25:16 +0000 Subject: [PATCH 059/178] Bump version to 2025.2.0b3 --- 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 271226e92e2..939eb70c3e4 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 = "0b2" +PATCH_VERSION: Final = "0b3" __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 74d634ea1a6..c8159776f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b2" +version = "2025.2.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b9884f72c32bedba561ae6d7121c1892926c08a6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Jan 2025 16:47:09 +0100 Subject: [PATCH 060/178] Shorten the integration name for `incomfort` (#136930) --- .../components/incomfort/manifest.json | 2 +- .../components/incomfort/strings.json | 22 +++++++++---------- homeassistant/generated/integrations.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index f4d752bfa48..d02b1d27554 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -1,6 +1,6 @@ { "domain": "incomfort", - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "codeowners": ["@jbouwh"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 4c47d4c57ad..15e28b6e0b9 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -2,20 +2,20 @@ "config": { "step": { "user": { - "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.", + "host": "Hostname or IP-address of the Intergas gateway.", "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { - "title": "Set up Intergas InComfort Lan2RF Gateway", + "title": "Set up Intergas gateway", "description": "Please enter authentication details for gateway {host}", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -23,12 +23,12 @@ }, "data_description": { "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." } }, "dhcp_confirm": { - "title": "Set up Intergas InComfort Lan2RF Gateway", - "description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?" + "title": "Set up Intergas gateway", + "description": "Do you want to set up the discovered Intergas gateway ({host})?" }, "reauth_confirm": { "data": { @@ -48,9 +48,9 @@ "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", - "not_found": "No Lan2RF gateway found.", - "timeout_error": "Time out when connecting to Lan2RF gateway.", - "unknown": "Unknown error when connecting to Lan2RF gateway." + "not_found": "No gateway found.", + "timeout_error": "Time out when connecting to the gateway.", + "unknown": "Unknown error when connecting to the gateway." } }, "exceptions": { @@ -70,7 +70,7 @@ "options": { "step": { "init": { - "title": "Intergas InComfort Lan2RF Gateway options", + "title": "Intergas gateway options", "data": { "legacy_setpoint_status": "Legacy setpoint handling" }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8a4290bb7d..cab624ecb5b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2866,7 +2866,7 @@ "iot_class": "local_polling" }, "incomfort": { - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" From bbb03d6731a43299dfba0c13d94eb5befc71a7f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 21:29:31 +0100 Subject: [PATCH 061/178] Update Overseerr string to mention CSRF (#137001) * Update Overseerr string to mention CSRF * Update homeassistant/components/overseerr/strings.json * Update homeassistant/components/overseerr/strings.json --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/overseerr/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 5053bcedc41..14650fd5c25 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -27,7 +27,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_auth": "Authentication failed. Your API key is invalid or CSRF protection is turned on, preventing authentication.", "invalid_host": "The provided URL is not a valid host." } }, From 4687b2e45511fd56b00fbae9935a5c302284fc8c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 20:59:34 +0100 Subject: [PATCH 062/178] Use readable backup names for onedrive (#137031) * Use readable names for onedrive * ensure filename is fixed * fix import --- homeassistant/components/onedrive/backup.py | 67 ++++++++++++--------- tests/components/onedrive/conftest.py | 5 +- tests/components/onedrive/test_backup.py | 38 ++---------- 3 files changed, 49 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 7f4bd5a0738..a7bac5d01fc 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -34,7 +34,12 @@ from msgraph.generated.models.drive_item_uploadable_properties import ( ) from msgraph_core.models import LargeFileUploadSession -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + suggested_filename, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client @@ -130,6 +135,10 @@ class OneDriveBackupAgent(BackupAgent): ) -> AsyncIterator[bytes]: """Download a backup file.""" # this forces the query to return a raw httpx response, but breaks typing + backup = await self._find_item_by_backup_id(backup_id) + if backup is None or backup.id is None: + raise BackupAgentError("Backup not found") + request_config = ( ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( options=[ResponseHandlerOption(NativeResponseHandler())], @@ -137,7 +146,7 @@ class OneDriveBackupAgent(BackupAgent): ) response = cast( Response, - await self._get_backup_file_item(backup_id).content.get( + await self._items.by_drive_item_id(backup.id).content.get( request_configuration=request_config ), ) @@ -162,9 +171,10 @@ class OneDriveBackupAgent(BackupAgent): }, ) ) - upload_session = await self._get_backup_file_item( - backup.backup_id - ).create_upload_session.post(upload_session_request_body) + file_item = self._get_backup_file_item(suggested_filename(backup)) + upload_session = await file_item.create_upload_session.post( + upload_session_request_body + ) if upload_session is None or upload_session.upload_url is None: raise BackupAgentError( @@ -181,9 +191,7 @@ class OneDriveBackupAgent(BackupAgent): description = json.dumps(backup_dict) _LOGGER.debug("Creating metadata: %s", description) - await self._get_backup_file_item(backup.backup_id).patch( - DriveItem(description=description) - ) + await file_item.patch(DriveItem(description=description)) @handle_backup_errors async def async_delete_backup( @@ -192,13 +200,10 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - - try: - await self._get_backup_file_item(backup_id).delete() - except APIError as err: - if err.response_status_code == 404: - return - raise + backup = await self._find_item_by_backup_id(backup_id) + if backup is None or backup.id is None: + return + await self._items.by_drive_item_id(backup.id).delete() @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: @@ -218,18 +223,12 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - try: - drive_item = await self._get_backup_file_item(backup_id).get() - except APIError as err: - if err.response_status_code == 404: - return None - raise - if ( - drive_item is not None - and (description := drive_item.description) is not None - ): - return self._backup_from_description(description) - return None + backup = await self._find_item_by_backup_id(backup_id) + if backup is None: + return None + + assert backup.description # already checked in _find_item_by_backup_id + return self._backup_from_description(backup.description) def _backup_from_description(self, description: str) -> AgentBackup: """Create a backup object from a description.""" @@ -238,8 +237,20 @@ class OneDriveBackupAgent(BackupAgent): ) # OneDrive encodes the description on save automatically return AgentBackup.from_dict(json.loads(description)) + async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None: + """Find a backup item by its backup ID.""" + + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if backup_id in description: + return item + return None + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: - return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:") async def _upload_file( self, upload_url: str, stream: AsyncIterator[bytes], total_size: int diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 649966a7828..205f5837ee7 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -125,7 +125,10 @@ def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: drive_items.children.get = AsyncMock( return_value=DriveItemCollectionResponse( value=[ - DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem( + id=BACKUP_METADATA["backup_id"], + description=escape(dumps(BACKUP_METADATA)), + ), DriveItem(), ] ) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 162ecb7d92a..0114d924e1a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -164,7 +164,7 @@ async def test_agents_delete_not_found_does_not_throw( mock_drive_items: MagicMock, ) -> None: """Test agent delete backup.""" - mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + mock_drive_items.children.get = AsyncMock(return_value=[]) client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -177,7 +177,7 @@ async def test_agents_delete_not_found_does_not_throw( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_drive_items.delete.assert_called_once() + assert mock_drive_items.delete.call_count == 0 async def test_agents_upload( @@ -448,22 +448,14 @@ async def test_delete_error( } -@pytest.mark.parametrize( - "problem", - [ - AsyncMock(return_value=None), - AsyncMock(side_effect=APIError(response_status_code=404)), - ], -) async def test_agents_backup_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_drive_items: MagicMock, - problem: AsyncMock, ) -> None: """Test backup not found.""" - mock_drive_items.get = problem + mock_drive_items.children.get = AsyncMock(return_value=[]) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -473,26 +465,6 @@ async def test_agents_backup_not_found( assert response["result"]["backup"] is None -async def test_agents_backup_error( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test backup not found.""" - - mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) - backup_id = BACKUP_METADATA["backup_id"] - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == { - f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" - } - - async def test_reauth_on_403( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -501,7 +473,9 @@ async def test_reauth_on_403( ) -> None: """Test we re-authenticate on 403.""" - mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + mock_drive_items.children.get = AsyncMock( + side_effect=APIError(response_status_code=403) + ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) From ea519268b6701f57c5b1378effa8d8e7dcb67a75 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Fri, 31 Jan 2025 21:03:17 +0100 Subject: [PATCH 063/178] Bump bthome-ble to 3.11.0 (#137032) bump bthome-ble to 3.11.0 --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ad06f648d14..3783c087971 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.9.1"] + "requirements": ["bthome-ble==3.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f321be6254f..594d2777e1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -665,7 +665,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.11.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28f181530a3..1f264a7b7ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -582,7 +582,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.11.0 # homeassistant.components.buienradar buienradar==1.0.6 From 321ce698bec92ad35cfe6db826b077e4ba14ab28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:00:39 -0600 Subject: [PATCH 064/178] Bump zeroconf to 0.143.0 (#137035) --- 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 be6f2d111d7..f4a78cd99e9 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.142.0"] + "requirements": ["zeroconf==0.143.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a1b97abc55..88527d7169a 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.142.0 +zeroconf==0.143.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index c8159776f8a..e912a2c274f 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.142.0" + "zeroconf==0.143.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 02f3849148b..13f19304cbb 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.142.0 +zeroconf==0.143.0 diff --git a/requirements_all.txt b/requirements_all.txt index 594d2777e1d..575f7bc8252 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.142.0 +zeroconf==0.143.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f264a7b7ba..9bf8b674bbb 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.142.0 +zeroconf==0.143.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From 90c357c01f3c8ec233598567e0984c833b34a153 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:18:19 -0600 Subject: [PATCH 065/178] Bump bthome-ble to 3.12.3 (#137036) --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 36 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 3783c087971..c8577113804 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.11.0"] + "requirements": ["bthome-ble==3.12.3"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 417df9f5068..e46cbbea700 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -67,6 +67,16 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + # Conductivity (µS/cm) + ( + BTHomeSensorDeviceClass.CONDUCTIVITY, + Units.CONDUCTIVITY, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", + device_class=SensorDeviceClass.CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + state_class=SensorStateClass.MEASUREMENT, + ), # Count (-) (BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription( key=str(BTHomeSensorDeviceClass.COUNT), @@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Directions (°) + (BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + ), # Distance (mm) ( BTHomeSensorDeviceClass.DISTANCE, @@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), + # Precipitation (mm) + ( + BTHomeExtendedSensorDeviceClass.PRECIPITATION, + Units.LENGTH_MILLIMETERS, + ): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}", + device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), # Pressure (mbar) (BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}", @@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, ), - # Conductivity (µS/cm) - ( - BTHomeSensorDeviceClass.CONDUCTIVITY, - Units.CONDUCTIVITY, - ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", - device_class=SensorDeviceClass.CONDUCTIVITY, - native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, - state_class=SensorStateClass.MEASUREMENT, - ), } diff --git a/requirements_all.txt b/requirements_all.txt index 575f7bc8252..ae8035f6a84 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -665,7 +665,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.11.0 +bthome-ble==3.12.3 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bf8b674bbb..9f8ab5809bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -582,7 +582,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.11.0 +bthome-ble==3.12.3 # homeassistant.components.buienradar buienradar==1.0.6 From 2b510caa1c1bc839e890012524b00442d1f08f0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 16:30:20 -0600 Subject: [PATCH 066/178] Bump aiohttp-asyncmdnsresolver to 0.0.3 (#137040) --- 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 88527d7169a..76bfa8b1ded 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.2 +aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index e912a2c274f..2890694fb64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.2", + "aiohttp-asyncmdnsresolver==0.0.3", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 13f19304cbb..a58065a3a7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.2 +aiohttp-asyncmdnsresolver==0.0.3 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From df35d226d6603235ea630fa43d1689a1d8d6d55d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 18:13:27 -0600 Subject: [PATCH 067/178] Bump habluetooth to 3.17.1 (#137045) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/bluetooth/test_diagnostics.py | 33 +++++++++++++++++-- .../bluetooth/test_websocket_api.py | 22 +++++++++++-- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index d6ed9281099..51358f8a656 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.17.0" + "habluetooth==3.17.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 76bfa8b1ded..40bb031d2ec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.17.0 +habluetooth==3.17.1 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index ae8035f6a84..fa505558a51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.0 +habluetooth==3.17.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f8ab5809bd..0ab3ab128b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.0 +habluetooth==3.17.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 384eae7e49a..682cff62969 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,7 +133,20 @@ async def test_diagnostics( } }, "manager": { - "allocations": {}, + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + "00:00:00:00:00:02": { + "allocated": [], + "free": 2, + "slots": 2, + "source": "00:00:00:00:00:02", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -292,7 +305,14 @@ async def test_diagnostics_macos( } }, "manager": { - "allocations": {}, + "allocations": { + "Core Bluetooth": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "Core Bluetooth", + }, + }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -486,7 +506,14 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { - "allocations": {}, + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index bacdbbd5eed..57199d04078 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -159,12 +159,30 @@ async def test_subscribe_connection_allocations( response = await client.receive_json() assert response["event"] == [ + { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI0_SOURCE_ADDRESS, + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI1_SOURCE_ADDRESS, + }, { "allocated": [], "free": 0, "slots": 0, "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, - } + }, ] manager = _get_manager() @@ -184,7 +202,7 @@ async def test_subscribe_connection_allocations( "free": 4, "slots": 5, "source": "AA:BB:CC:DD:EE:11", - } + }, ] manager.async_on_allocation_changed( Allocations( From 9935528dd3d874cfc6caf573c4705910d253cd03 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Feb 2025 01:38:11 +0100 Subject: [PATCH 068/178] Bump aioimaplib to version 2.0.1 (#137049) --- homeassistant/components/imap/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index a3370de94ca..515fee0e721 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==2.0.0"] + "requirements": ["aioimaplib==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa505558a51..48b33fdbd2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==2.0.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ab3ab128b7..8c62ade0a6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -258,7 +258,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==2.0.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 829a6271af547001f748bd76c40e4151444e7b4a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Feb 2025 01:04:55 +0000 Subject: [PATCH 069/178] Bump version to 2025.2.0b4 --- 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 939eb70c3e4..c7aecaca83d 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 = "0b3" +PATCH_VERSION: Final = "0b4" __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 2890694fb64..ad070fa04ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b3" +version = "2025.2.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a4eab35e01a435c5eaaee9337af6eaecffd2c6cb Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 1 Feb 2025 05:53:04 -0700 Subject: [PATCH 070/178] Raise HomeAssistantError from camera snapshot service (#137051) * Raise HomeAssistantError from camera snapshot service * Improve error message --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/camera/__init__.py | 19 ++++++++++++------- tests/components/camera/test_init.py | 21 +++++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 556f8d75fc4..aa5d766c874 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -1175,12 +1175,17 @@ async def async_handle_snapshot_service( f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" ) - async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT): - image = ( - await _async_get_stream_image(camera, wait_for_next_keyframe=True) - if camera.use_stream_for_stills - else await camera.async_camera_image() - ) + try: + async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT): + image = ( + await _async_get_stream_image(camera, wait_for_next_keyframe=True) + if camera.use_stream_for_stills + else await camera.async_camera_image() + ) + except TimeoutError as err: + raise HomeAssistantError( + f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds" + ) from err if image is None: return @@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service( try: await hass.async_add_executor_job(_write_image, snapshot_file, image) except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + raise HomeAssistantError(f"Can't write image to file: {err}") from err async def async_handle_play_stream_service( diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 5a26e3b44f6..7fd469fa51a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -301,13 +301,24 @@ async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_camera") -async def test_snapshot_service_os_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + ("target", "side_effect"), + [ + ("homeassistant.components.camera.os.makedirs", OSError), + ( + "homeassistant.components.demo.camera.DemoCamera.async_camera_image", + TimeoutError, + ), + ], +) +async def test_snapshot_service_error( + hass: HomeAssistant, target: str, side_effect: Exception ) -> None: - """Test snapshot service with os error.""" + """Test snapshot service with error.""" with ( patch.object(hass.config, "is_allowed_path", return_value=True), - patch("homeassistant.components.camera.os.makedirs", side_effect=OSError), + patch(target, side_effect=side_effect), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( camera.DOMAIN, @@ -319,8 +330,6 @@ async def test_snapshot_service_os_error( blocking=True, ) - assert "Can't write image to file:" in caplog.text - @pytest.mark.usefixtures("mock_camera", "mock_stream") async def test_websocket_stream_no_source( From 3a11e8df6a30c868feb18071a729a00260f6bf4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 20:24:01 -0600 Subject: [PATCH 071/178] Allow ignored govee-ble devices to be set up from the user flow (#137052) * Allow ignored govee-ble devices to be setup up from the user flow Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Allow ignored devices to be selected in the user step and replace the ignored entry. * Add the ability to skip ignored config entries when calling _abort_if_unique_id_configured see https://github.com/home-assistant/core/pull/137052 * coverage * revert --- .../components/govee_ble/config_flow.py | 2 +- .../components/govee_ble/test_config_flow.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/govee_ble/config_flow.py b/homeassistant/components/govee_ble/config_flow.py index 2cc47435abf..d48fffdd633 100644 --- a/homeassistant/components/govee_ble/config_flow.py +++ b/homeassistant/components/govee_ble/config_flow.py @@ -78,7 +78,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN): title=title, data={CONF_DEVICE_TYPE: device.device_type} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/govee_ble/test_config_flow.py b/tests/components/govee_ble/test_config_flow.py index eb0719f832c..ac8970ca977 100644 --- a/tests/components/govee_ble/test_config_flow.py +++ b/tests/components/govee_ble/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" +async def test_async_step_user_replace_ignored_device(hass: HomeAssistant) -> None: + """Test setup user step can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=GVH5177_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.govee_ble.config_flow.async_discovered_service_info", + return_value=[GVH5177_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.govee_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "H5177 2EC8" + assert result2["data"] == {CONF_DEVICE_TYPE: "H5177"} + assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From ec22479733864fce4b8403b9147ccb249125e617 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 20:25:16 -0600 Subject: [PATCH 072/178] Allow ignored switchbot devices to be set up from the user flow (#137056) --- .../components/switchbot/config_flow.py | 2 +- .../components/switchbot/test_config_flow.py | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 31c0c42168d..04b4e20b7ce 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -272,7 +272,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _async_discover_devices(self) -> None: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for connectable in (True, False): for discovery_info in async_discovered_service_info(self.hass, connectable): address = discovery_info.address diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 3caa2a1f0df..1038bd318f5 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.switchbot.const import ( CONF_LOCK_NIGHTLATCH, CONF_RETRY_COUNT, ) -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import ( CONF_ADDRESS, CONF_NAME, @@ -303,6 +303,39 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None assert result["reason"] == "no_devices_found" +async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: + """Test setting up a switchbot replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" From a51846a8cdb06ec188619bee2a5b6fba516279b7 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 31 Jan 2025 21:40:52 -0800 Subject: [PATCH 073/178] For consistency use suggested_filename in Google Drive (#137061) Use suggested_filename in Google Drive --- homeassistant/components/google_drive/api.py | 4 ++-- tests/components/google_drive/snapshots/test_backup.ambr | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index a26512db35b..475eddb6231 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -11,7 +11,7 @@ from aiohttp import ClientSession, ClientTimeout, StreamReader from aiohttp.client_exceptions import ClientError, ClientResponseError from google_drive_api.api import AbstractAuth, GoogleDriveApi -from homeassistant.components.backup import AgentBackup +from homeassistant.components.backup import AgentBackup, suggested_filename from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.exceptions import ( @@ -132,7 +132,7 @@ class DriveClient: """Upload a backup.""" folder_id, _ = await self.async_create_ha_root_folder_if_not_exists() backup_metadata = { - "name": f"{backup.name} {backup.date}.tar", + "name": suggested_filename(backup), "description": json.dumps(backup.as_dict()), "parents": [folder_id], "properties": { diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 0832682b74d..9e1ec00b52e 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -140,7 +140,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'name': 'Test_-_2025-01-01_01.23_45678000.tar', 'parents': list([ 'HA folder ID', ]), @@ -211,7 +211,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'name': 'Test_-_2025-01-01_01.23_45678000.tar', 'parents': list([ 'new folder id', ]), From 3588b88cbba9e7be6e87c9c27d8af12bfbb5efa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 07:26:31 -0600 Subject: [PATCH 074/178] Bump habluetooth to 3.20.1 (#137063) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 51358f8a656..f43940821a1 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.17.1" + "habluetooth==3.20.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 40bb031d2ec..3d3c43470f7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.17.1 +habluetooth==3.20.1 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 48b33fdbd2f..730fc1b4990 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.1 +habluetooth==3.20.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c62ade0a6d..f49383d0e15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.1 +habluetooth==3.20.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 6efa6f9687b66026089e04d54c84537d8fbdf59d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 1 Feb 2025 11:43:45 +0100 Subject: [PATCH 075/178] Load `hassio` before `backup` at frontend stage (#137067) --- homeassistant/bootstrap.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 8c27f41aabe..490ce5559a9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -161,6 +161,12 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", + # Hassio is an after dependency of backup, after dependencies + # are not promoted from stage 2 to earlier stages, so we need to + # add it here. Hassio needs to be setup before backup, otherwise + # the backup integration will think we are a container/core install + # when using HAOS or Supervised install. + "hassio", # Backup is an after dependency of frontend, after dependencies # are not promoted from stage 2 to earlier stages, so we need to # add it here. From b3c44ca03a398d91412430b58b7f0b08c31a67c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Feb 2025 13:58:56 +0000 Subject: [PATCH 076/178] Bump version to 2025.2.0b5 --- 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 c7aecaca83d..806b896d20f 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 = "0b4" +PATCH_VERSION: Final = "0b5" __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 ad070fa04ae..4930723d125 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b4" +version = "2025.2.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fa8d1b4dc41df1cc6c27aff14611b82a4f5b5527 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 1 Feb 2025 10:16:10 -0500 Subject: [PATCH 077/178] Bump lacrosse-view to 1.0.4 (#137058) --- homeassistant/components/lacrosse_view/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 453a0855229..86b2f61a872 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", "loggers": ["lacrosse_view"], - "requirements": ["lacrosse-view==1.0.3"] + "requirements": ["lacrosse-view==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 730fc1b4990..224d9510795 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.3 +lacrosse-view==1.0.4 # homeassistant.components.eufy lakeside==0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f49383d0e15..fa5ac9ddae4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1083,7 +1083,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.3 +lacrosse-view==1.0.4 # homeassistant.components.laundrify laundrify-aio==1.2.2 From e76ff0a0deb41c21dc3a9e1c962b4a582832b05b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 1 Feb 2025 16:15:55 +0100 Subject: [PATCH 078/178] Update RestrictedPython to 8.0 (#137075) --- homeassistant/components/python_script/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 4348fdd9911..c8cb1da40c9 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==7.4"] + "requirements": ["RestrictedPython==8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 224d9510795..dd220389cde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -109,7 +109,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==7.4 +RestrictedPython==8.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa5ac9ddae4..71b839c6f8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,7 +103,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==7.4 +RestrictedPython==8.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 2d1d9bbe5a687e2d41896d18193d6bb74f5ba624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 12:10:59 -0600 Subject: [PATCH 079/178] Set via_device for remote Bluetooth adapters to link to the parent device (#137091) --- .../components/bluetooth/__init__.py | 17 +++++- homeassistant/components/bluetooth/api.py | 8 ++- .../components/bluetooth/config_flow.py | 2 + homeassistant/components/bluetooth/const.py | 2 +- homeassistant/components/bluetooth/manager.py | 6 +-- homeassistant/components/esphome/bluetooth.py | 2 + homeassistant/components/esphome/manager.py | 4 +- .../components/shelly/bluetooth/__init__.py | 2 + .../components/shelly/coordinator.py | 5 +- .../components/bluetooth/test_config_flow.py | 52 ++++++++++++------- tests/components/esphome/test_bluetooth.py | 29 +++++++++++ 11 files changed, 101 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 5edec1ccc23..c423e9e747b 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -80,6 +80,7 @@ from .const import ( CONF_DETAILS, CONF_PASSIVE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, @@ -297,7 +298,12 @@ async def async_discover_adapters( async def async_update_device( - hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails + hass: HomeAssistant, + entry: ConfigEntry, + adapter: str, + details: AdapterDetails, + via_device_domain: str | None = None, + via_device_id: str | None = None, ) -> None: """Update device registry entry. @@ -306,7 +312,8 @@ async def async_update_device( update the device with the new location so they can figure out where the adapter is. """ - dr.async_get(hass).async_get_or_create( + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, @@ -315,6 +322,10 @@ async def async_update_device( sw_version=details.get(ADAPTER_SW_VERSION), hw_version=details.get(ADAPTER_HW_VERSION), ) + if via_device_id: + device_registry.async_update_device( + device_entry.id, via_device_id=via_device_id + ) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, source_entry.title, details, + source_domain, + entry.data.get(CONF_SOURCE_DEVICE_ID), ) return True manager = _get_manager(hass) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9db570c4cba..00e585fa266 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -181,10 +181,16 @@ def async_register_scanner( source_domain: str | None = None, source_model: str | None = None, source_config_entry_id: str | None = None, + source_device_id: str | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" return _get_manager(hass).async_register_hass_scanner( - scanner, connection_slots, source_domain, source_model, source_config_entry_id + scanner, + connection_slots, + source_domain, + source_model, + source_config_entry_id, + source_device_id, ) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 6425aabe12f..5d03a9c9d0f 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -37,6 +37,7 @@ from .const import ( CONF_PASSIVE, CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, @@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID], } self._abort_if_unique_id_configured(updates=data) manager = get_manager() diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index d4b187d4605..22c885b4f8b 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source" CONF_SOURCE_DOMAIN: Final = "source_domain" CONF_SOURCE_MODEL: Final = "source_model" CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id" - +CONF_SOURCE_DEVICE_ID: Final = "source_device_id" SOURCE_LOCAL: Final = "local" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 09be8f960e9..46c5425c730 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, @@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): source_domain: str | None = None, source_model: str | None = None, source_config_entry_id: str | None = None, + source_device_id: str | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" cancel = self.async_register_scanner(scanner, connection_slots) @@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager): isinstance(scanner, BaseHaRemoteScanner) and source_domain and source_config_entry_id - and not self.hass.config_entries.async_entry_for_domain_unique_id( - DOMAIN, scanner.source - ) ): self.hass.async_create_task( self.hass.config_entries.flow.async_init( @@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): CONF_SOURCE_DOMAIN: source_domain, CONF_SOURCE_MODEL: source_model, CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, + CONF_SOURCE_DEVICE_ID: source_device_id, }, ) ) diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index da342913d3d..27abb19909f 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -28,6 +28,7 @@ def async_connect_scanner( entry_data: RuntimeEntryData, cli: APIClient, device_info: DeviceInfo, + device_id: str, ) -> CALLBACK_TYPE: """Connect scanner.""" client_data = connect_scanner(cli, device_info, entry_data.available) @@ -45,6 +46,7 @@ def async_connect_scanner( source_domain=DOMAIN, source_model=device_info.model, source_config_entry_id=entry_data.entry_id, + source_device_id=device_id, ), scanner.async_setup(), ], diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 93d6c53e590..218ea1c193d 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -425,7 +425,9 @@ class ESPHomeManager: if device_info.bluetooth_proxy_feature_flags_compat(api_version): entry_data.disconnect_callbacks.add( - async_connect_scanner(hass, entry_data, cli, device_info) + async_connect_scanner( + hass, entry_data, cli, device_info, self.device_id + ) ) else: bluetooth.async_remove_scanner(hass, device_info.mac_address) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 5200ec9b913..366d5c51d25 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -21,6 +21,7 @@ async def async_connect_scanner( hass: HomeAssistant, coordinator: ShellyRpcCoordinator, scanner_mode: BLEScannerMode, + device_id: str, ) -> CALLBACK_TYPE: """Connect scanner.""" device = coordinator.device @@ -34,6 +35,7 @@ async def async_connect_scanner( source_domain=entry.domain, source_model=coordinator.model, source_config_entry_id=entry.entry_id, + source_device_id=device_id, ), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d5071c4e849..f2a01240f70 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -704,8 +704,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway return + assert self.device_id is not None self._disconnected_callbacks.append( - await async_connect_scanner(self.hass, self, ble_scanner_mode) + await async_connect_scanner( + self.hass, self, ble_scanner_mode, self.device_id + ) ) @callback diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 0070bebe4b6..b8f90b3a4aa 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -13,12 +13,14 @@ from homeassistant.components.bluetooth.const import ( CONF_PASSIVE, CONF_SOURCE, CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, CONF_SOURCE_DOMAIN, CONF_SOURCE_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import FakeRemoteScanner, MockBleakClient, _get_manager @@ -535,34 +537,33 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> @pytest.mark.usefixtures("enable_bluetooth") async def test_async_step_integration_discovery_remote_adapter( - hass: HomeAssistant, + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test remote adapter configuration via integration discovery.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) scanner = FakeRemoteScanner("esp32", "esp32", connector, True) manager = _get_manager() cancel_scanner = manager.async_register_scanner(scanner) + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "BB:BB:BB:BB:BB:BB")}, + ) - entry.add_to_hass(hass) - with ( - patch("homeassistant.components.bluetooth.async_setup", return_value=True), - patch( - "homeassistant.components.bluetooth.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_SOURCE: scanner.source, - CONF_SOURCE_DOMAIN: "test", - CONF_SOURCE_MODEL: "test", - CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, - }, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: device_entry.id, + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "esp32" assert result["data"] == { @@ -570,9 +571,22 @@ async def test_async_step_integration_discovery_remote_adapter( CONF_SOURCE_DOMAIN: "test", CONF_SOURCE_MODEL: "test", CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: device_entry.id, } - assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done() + + new_entry_id: str = result["result"].entry_id + new_entry = hass.config_entries.async_get_entry(new_entry_id) + assert new_entry is not None + assert new_entry.state is config_entries.ConfigEntryState.LOADED + + ble_device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert ble_device_entry is not None + assert ble_device_entry.via_device_id == device_entry.id + + await hass.config_entries.async_unload(new_entry.entry_id) await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() cancel_scanner() diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 31d9fcd34f9..19bc5a2e7c7 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import MockESPHomeDevice @@ -48,6 +49,34 @@ async def test_bluetooth_connect_with_legacy_adv( assert scanner.scanning is True +async def test_bluetooth_device_linked_via_device( + hass: HomeAssistant, + mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the Bluetooth device is linked to the ESPHome device.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.connectable is True + entry = hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", "11:22:33:44:55:AA" + ) + assert entry is not None + esp_device = device_registry.async_get_device( + connections={ + ( + dr.CONNECTION_NETWORK_MAC, + mock_bluetooth_entry_with_raw_adv.device_info.mac_address, + ) + } + ) + assert esp_device is not None + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + ) + assert device is not None + assert device.via_device_id == esp_device.id + + async def test_bluetooth_cleanup_on_remove_entry( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: From bad966f3ab9133dae86f8185121f5b0bcbebe0e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:22:13 -0600 Subject: [PATCH 080/178] Allow ignored airthings_ble devices to be set up from the user flow (#137102) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for airthings --- .../components/airthings_ble/config_flow.py | 2 +- .../airthings_ble/test_config_flow.py | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 48c7219cbaf..3e7b659bff1 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=discovery.name, data={}) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 79ae46500dd..314594c612f 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -7,7 +7,7 @@ from bleak import BleakError import pytest from homeassistant.components.airthings_ble.const import DOMAIN -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -153,6 +153,57 @@ async def test_user_setup(hass: HomeAssistant) -> None: assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" +async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + source=SOURCE_IGNORE, + data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}, + ) + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ), + patch_async_ble_device_from_address(WAVE_SERVICE_INFO), + patch_airthings_ble( + AirthingsDevice( + manufacturer="Airthings AS", + model=AirthingsDeviceType.WAVE_PLUS, + name="Airthings Wave Plus", + identifier="123456", + ) + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus" + } + + with patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"} + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave Plus (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + async def test_user_setup_no_device(hass: HomeAssistant) -> None: """Test the user initiated form without any device detected.""" with patch( From 76937541f118356e82da50c930cbb0141f59c3d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:21:53 -0600 Subject: [PATCH 081/178] Allow ignored yale_ble devices to be set up from the user flow (#137103) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for yalexs_ble --- .../components/yalexs_ble/config_flow.py | 2 +- .../components/yalexs_ble/test_config_flow.py | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 6de74759686..0e1eabdf6b2 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -267,7 +267,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) current_unique_names = { entry.data.get(CONF_LOCAL_NAME) for entry in self._async_current_entries() diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index c546e754239..1b0df05db2c 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -92,6 +92,58 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize("slot", [0, 1, 66]) +async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None: + """Test user step replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.yalexs_ble.config_flow.async_discovered_service_info", + return_value=[NOT_YALE_DISCOVERY_INFO, YALE_ACCESS_LOCK_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", + ), + patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: slot, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: slot, + } + assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( From 9b5c21524cc78c6dbcd9b18730d0df16eb208c22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:21:43 -0600 Subject: [PATCH 082/178] Allow ignored thermopro devices to be set up from the user flow (#137104) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for thermopro --- .../components/thermopro/config_flow.py | 2 +- .../components/thermopro/test_config_flow.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/thermopro/config_flow.py b/homeassistant/components/thermopro/config_flow.py index 4d080c6e074..4c6d59473c2 100644 --- a/homeassistant/components/thermopro/config_flow.py +++ b/homeassistant/components/thermopro/config_flow.py @@ -72,7 +72,7 @@ class ThermoProConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/thermopro/test_config_flow.py b/tests/components/thermopro/test_config_flow.py index 9b9fdd67334..3cf68fb612c 100644 --- a/tests/components/thermopro/test_config_flow.py +++ b/tests/components/thermopro/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info and replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TP357_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.thermopro.config_flow.async_discovered_service_info", + return_value=[TP357_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.thermopro.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "TP357 (2142) AC3D" + assert result2["data"] == {} + assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 4c6fda20967c58b798c3722eb388416cbec6e433 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:08:24 -0600 Subject: [PATCH 083/178] Allow ignored bthome devices to be set up from the user flow (#137105) --- .../components/bthome/config_flow.py | 2 +- tests/components/bthome/test_config_flow.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 24fdddf2cc7..524365c1183 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_get_or_create_entry() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/bthome/test_config_flow.py b/tests/components/bthome/test_config_flow.py index faf2f1c9ef5..5aea3a3cc9b 100644 --- a/tests/components/bthome/test_config_flow.py +++ b/tests/components/bthome/test_config_flow.py @@ -213,6 +213,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "54:48:E6:8F:80:A5" +async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info cache replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="54:48:E6:8F:80:A5", + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.bthome.config_flow.async_discovered_service_info", + return_value=[PRST_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.bthome.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "54:48:E6:8F:80:A5"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "b-parasite 80A5" + assert result2["data"] == {} + assert result2["result"].unique_id == "54:48:E6:8F:80:A5" + + async def test_async_step_user_with_found_devices_encryption( hass: HomeAssistant, ) -> None: From cb4b7e71af0dcd6926599b42542d8cb6f2d3d9b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:21:21 -0600 Subject: [PATCH 084/178] Allow ignored inkbird devices to be set up from the user flow (#137106) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for inkbird --- .../components/inkbird/config_flow.py | 2 +- tests/components/inkbird/test_config_flow.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 0d4e404c9b5..09dd31a9cf6 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 154132c34fc..796f57da55b 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -75,6 +75,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SPS_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.inkbird.config_flow.async_discovered_service_info", + return_value=[SPS_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.inkbird.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "61DE521B-F0BF-9F44-64D4-75BBE1738105"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "IBS-TH 8105" + assert result2["data"] == {} + assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 434a4ebc9fb7b2efc6bde5995189cb5ffefdf331 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:21:09 -0600 Subject: [PATCH 085/178] Allow ignored mopeka devices to be set up from the user flow (#137107) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for mopeka --- .../components/mopeka/config_flow.py | 2 +- tests/components/mopeka/test_config_flow.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index 2e35ff4283f..e5b7d5d7dd2 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -111,7 +111,7 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/mopeka/test_config_flow.py b/tests/components/mopeka/test_config_flow.py index 7a341052f22..d2887451629 100644 --- a/tests/components/mopeka/test_config_flow.py +++ b/tests/components/mopeka/test_config_flow.py @@ -81,6 +81,39 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=PRO_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.mopeka.config_flow.async_discovered_service_info", + return_value=[PRO_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.mopeka.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pro Plus EEFF" + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 3b67dc36516bf85d433f86da358bea51e56a2529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:20:52 -0600 Subject: [PATCH 086/178] Allow ignored oralb devices to be set up from the user flow (#137109) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for oralb --- homeassistant/components/oralb/config_flow.py | 2 +- tests/components/oralb/test_config_flow.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/oralb/config_flow.py b/homeassistant/components/oralb/config_flow.py index ab5d919194e..bac2d32bb2f 100644 --- a/homeassistant/components/oralb/config_flow.py +++ b/homeassistant/components/oralb/config_flow.py @@ -72,7 +72,7 @@ class OralBConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/oralb/test_config_flow.py b/tests/components/oralb/test_config_flow.py index dee16cd0632..c4cc830b89c 100644 --- a/tests/components/oralb/test_config_flow.py +++ b/tests/components/oralb/test_config_flow.py @@ -96,6 +96,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 73b874c5e60faaddd60ad14644749fb2c2e472f1 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Sat, 1 Feb 2025 20:49:09 +0200 Subject: [PATCH 087/178] Fix Homekit camera profiles schema (#137110) --- homeassistant/components/homekit/util.py | 5 +++++ tests/components/homekit/test_util.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index c36738b286d..1181ceaa953 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -78,6 +78,7 @@ from .const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -90,6 +91,7 @@ from .const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, @@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In( VALID_VIDEO_CODECS ), + vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [ + cv.string + ], vol.Optional( CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE ): cv.positive_int, diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 853db54b992..1da12402a56 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -26,6 +26,7 @@ from homeassistant.components.homekit.const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -39,6 +40,7 @@ from homeassistant.components.homekit.const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, @@ -235,6 +237,7 @@ def test_validate_entity_config() -> None: CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP, CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, + CONF_VIDEO_PROFILE_NAMES: DEFAULT_VIDEO_PROFILE_NAMES, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, From 63bd67f6cd3d167e46660c2e7b6713d637bb72fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:19:44 -0600 Subject: [PATCH 088/178] Allow ignored qingping devices to be set up from the user flow (#137111) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for qingping --- .../components/qingping/config_flow.py | 2 +- tests/components/qingping/test_config_flow.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index c5efe03a878..990eb5116eb 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -98,7 +98,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/qingping/test_config_flow.py b/tests/components/qingping/test_config_flow.py index 7bcd9c09e68..9d3d2a49e26 100644 --- a/tests/components/qingping/test_config_flow.py +++ b/tests/components/qingping/test_config_flow.py @@ -114,6 +114,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LIGHT_AND_SIGNAL_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.qingping.config_flow.async_discovered_service_info", + return_value=[LIGHT_AND_SIGNAL_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.qingping.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Motion & Light EEFF" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 4c8f716320d34b9da1664e29cbc4029c3204e332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:19:42 -0600 Subject: [PATCH 089/178] Allow ignored sensorpush devices to be set up from the user flow (#137113) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for sensorpush --- .../components/sensorpush/config_flow.py | 2 +- .../components/sensorpush/test_config_flow.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensorpush/config_flow.py b/homeassistant/components/sensorpush/config_flow.py index d826029276b..d3233ac2d5f 100644 --- a/homeassistant/components/sensorpush/config_flow.py +++ b/homeassistant/components/sensorpush/config_flow.py @@ -72,7 +72,7 @@ class SensorPushConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/sensorpush/test_config_flow.py b/tests/components/sensorpush/test_config_flow.py index 7e87dd1c6b8..194f4fc4a78 100644 --- a/tests/components/sensorpush/test_config_flow.py +++ b/tests/components/sensorpush/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HTW_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.sensorpush.config_flow.async_discovered_service_info", + return_value=[HTW_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.sensorpush.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "61DE521B-F0BF-9F44-64D4-75BBE1738105"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "HT.w 0CA1" + assert result2["data"] == {} + assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 21a85c014a6182c5fcab46ca2fb08c4541abbd2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 13:16:39 -0600 Subject: [PATCH 090/178] Allow ignored xiaomi_ble devices to be set up from the user flow (#137115) --- .../components/xiaomi_ble/config_flow.py | 2 +- .../components/xiaomi_ble/test_config_flow.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index df2de381d39..c293d7832d0 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -306,7 +306,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_get_or_create_entry() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index e25ac939a53..3d8a4dab244 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -634,6 +634,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "58:2D:34:35:93:21" +async def test_async_step_user_replace_ignored_entry(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LYWSDCGQ_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.xiaomi_ble.config_flow.async_discovered_service_info", + return_value=[LYWSDCGQ_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.xiaomi_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "58:2D:34:35:93:21"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Temperature/Humidity Sensor 9321 (LYWSDCGQ)" + assert result2["data"] == {} + assert result2["result"].unique_id == "58:2D:34:35:93:21" + + async def test_async_step_user_short_payload(hass: HomeAssistant) -> None: """Test setup from service info cache with devices found but short payloads.""" with patch( From 1ea23fda102ec1a6be1d81ec7c876e220b99ebb1 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 1 Feb 2025 22:03:19 +0200 Subject: [PATCH 091/178] Allow ignored Aranet devices to be set up from the user flow (#137121) --- .../components/aranet/config_flow.py | 2 +- tests/components/aranet/test_config_flow.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aranet/config_flow.py b/homeassistant/components/aranet/config_flow.py index db89124c54d..876b175126e 100644 --- a/homeassistant/components/aranet/config_flow.py +++ b/homeassistant/components/aranet/config_flow.py @@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address][0], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/aranet/test_config_flow.py b/tests/components/aranet/test_config_flow.py index 9596507960b..c40725c397d 100644 --- a/tests/components/aranet/test_config_flow.py +++ b/tests/components/aranet/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.aranet.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -275,3 +276,31 @@ async def test_async_step_user_integrations_disabled(hass: HomeAssistant) -> Non ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "integrations_disabled" + + +async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", source=SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aranet.config_flow.async_discovered_service_info", + return_value=[VALID_DATA_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.aranet.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"address": "aa:bb:cc:dd:ee:ff"} + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Aranet4 12345" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" From 6bf5e9508946f2833b7480aee520e0ca09985727 Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Sat, 1 Feb 2025 16:09:49 -0500 Subject: [PATCH 092/178] Allow ignored tilt_ble devices to be set up from user flow (#137123) Co-authored-by: J. Nick Koston --- .../components/tilt_ble/config_flow.py | 2 +- tests/components/tilt_ble/test_config_flow.py | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tilt_ble/config_flow.py b/homeassistant/components/tilt_ble/config_flow.py index 5c1f9721aae..b4a3235c60f 100644 --- a/homeassistant/components/tilt_ble/config_flow.py +++ b/homeassistant/components/tilt_ble/config_flow.py @@ -72,7 +72,7 @@ class TiltConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/tilt_ble/test_config_flow.py b/tests/components/tilt_ble/test_config_flow.py index fd996228034..9c9450f3996 100644 --- a/tests/components/tilt_ble/test_config_flow.py +++ b/tests/components/tilt_ble/test_config_flow.py @@ -79,6 +79,37 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "F6:0F:28:F2:1F:CB" +async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TILT_GREEN_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.tilt_ble.config_flow.async_discovered_service_info", + return_value=[TILT_GREEN_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.tilt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "F6:0F:28:F2:1F:CB"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tilt Green" + assert result2["data"] == {} + assert result2["result"].unique_id == "F6:0F:28:F2:1F:CB" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 1db5da4037f5e59dca1146088eb27975aecbdb04 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 1 Feb 2025 14:26:52 -0800 Subject: [PATCH 093/178] Remove entity state from mcp-server prompt (#137126) * Create a stateless assist API for MCP server * Update stateless API * Fix areas in exposed entity fields * Add tests that verify areas are returned * Revert the getstate intent * Revert whitespace change * Revert whitespace change * Revert method name changes to avoid breaking openai and google tests --- .../components/mcp_server/__init__.py | 3 +- .../components/mcp_server/config_flow.py | 8 +++- homeassistant/components/mcp_server/const.py | 2 + .../components/mcp_server/llm_api.py | 48 +++++++++++++++++++ homeassistant/helpers/llm.py | 22 ++++++++- tests/components/mcp_server/conftest.py | 5 +- tests/components/mcp_server/test_http.py | 35 +++++++++++--- 7 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/mcp_server/llm_api.py diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index e523f46228f..941eccbe528 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from . import http +from . import http, llm_api from .const import DOMAIN from .session import SessionManager from .types import MCPServerConfigEntry @@ -25,6 +25,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Model Context Protocol component.""" http.async_register(hass) + llm_api.async_register_api(hass) return True diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index 8d68c6a868a..8d8d311b874 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import DOMAIN +from .const import DOMAIN, LLM_API, LLM_API_NAME _LOGGER = logging.getLogger(__name__) @@ -33,6 +33,12 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} + if LLM_API not in llm_apis: + # MCP server component is not loaded yet, so make the LLM API a choice. + llm_apis = { + LLM_API: LLM_API_NAME, + **llm_apis, + } if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 1aa81f445a1..8958ac36616 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -2,3 +2,5 @@ DOMAIN = "mcp_server" TITLE = "Model Context Protocol Server" +LLM_API = "stateless_assist" +LLM_API_NAME = "Stateless Assist" diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py new file mode 100644 index 00000000000..f4292744815 --- /dev/null +++ b/homeassistant/components/mcp_server/llm_api.py @@ -0,0 +1,48 @@ +"""LLM API for MCP Server.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import llm +from homeassistant.util import yaml as yaml_util + +from .const import LLM_API, LLM_API_NAME + +EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} + + +def async_register_api(hass: HomeAssistant) -> None: + """Register the LLM API.""" + llm.async_register_api(hass, StatelessAssistAPI(hass)) + + +class StatelessAssistAPI(llm.AssistAPI): + """LLM API for MCP Server that provides the Assist API without state information in the prompt. + + Syncing the state information is possible, but may put unnecessary load on + the system so we are instead providing the prompt without entity state. Since + actions don't care about the current state, there is little quality loss. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the StatelessAssistAPI.""" + super().__init__(hass) + self.id = LLM_API + self.name = LLM_API_NAME + + @callback + def _async_get_exposed_entities_prompt( + self, llm_context: llm.LLMContext, exposed_entities: dict | None + ) -> list[str]: + """Return the prompt for the exposed entities.""" + prompt = [] + + if exposed_entities: + prompt.append( + "An overview of the areas and the devices in this smart home:" + ) + entities = [ + {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} + for entity_info in exposed_entities.values() + ] + prompt.append(yaml_util.dump(list(entities))) + + return prompt diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index cc397c5d428..2bca4c8528b 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -326,12 +326,21 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - """Return the prompt for the API.""" if not exposed_entities: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." ) + return "\n".join( + [ + *self._async_get_preable(llm_context), + *self._async_get_exposed_entities_prompt(llm_context, exposed_entities), + ] + ) + + @callback + def _async_get_preable(self, llm_context: LLMContext) -> list[str]: + """Return the prompt for the API.""" prompt = [ ( @@ -371,13 +380,22 @@ class AssistAPI(API): ): prompt.append("This device is not able to start timers.") + return prompt + + @callback + def _async_get_exposed_entities_prompt( + self, llm_context: LLMContext, exposed_entities: dict | None + ) -> list[str]: + """Return the prompt for the API for exposed entities.""" + prompt = [] + if exposed_entities: prompt.append( "An overview of the areas and the devices in this smart home:" ) prompt.append(yaml_util.dump(list(exposed_entities.values()))) - return "\n".join(prompt) + return prompt @callback def _async_get_tools( diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 149073f3645..5ec67fb6ce3 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.components.mcp_server.const import DOMAIN, LLM_API from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm from tests.common import MockConfigEntry @@ -28,7 +27,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: LLM_API, }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index a71bf42acc8..905bfaa11d7 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -20,7 +20,11 @@ from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, setup_test_component_platform @@ -45,6 +49,11 @@ INITIALIZE_MESSAGE = { } EVENT_PREFIX = "event: " DATA_PREFIX = "data: " +EXPECTED_PROMPT_SUFFIX = """ +- names: Kitchen Light + domain: light + areas: Kitchen +""" @pytest.fixture @@ -59,11 +68,13 @@ async def mock_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, setup_integration: None, ) -> None: """Fixture to expose entities to the conversation agent.""" - entity = MockLight("kitchen", STATE_OFF) + entity = MockLight("Kitchen Light", STATE_OFF) entity.entity_id = TEST_ENTITY + entity.unique_id = "test-light-unique-id" setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) assert await async_setup_component( @@ -71,6 +82,9 @@ async def mock_entities( LIGHT_DOMAIN, {LIGHT_DOMAIN: [{"platform": "test"}]}, ) + await hass.async_block_till_done() + kitchen = area_registry.async_get_or_create("Kitchen") + entity_registry.async_update_entity(TEST_ENTITY, area_id=kitchen.id) async_expose_entity(hass, CONVERSATION_DOMAIN, TEST_ENTITY, True) @@ -320,7 +334,7 @@ async def test_mcp_tool_call( async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", - arguments={"name": "kitchen"}, + arguments={"name": "kitchen light"}, ) assert not result.isError @@ -370,8 +384,11 @@ async def test_prompt_list( assert len(result.prompts) == 1 prompt = result.prompts[0] - assert prompt.name == "Assist" - assert prompt.description == "Default prompt for the Home Assistant LLM API Assist" + assert prompt.name == "Stateless Assist" + assert ( + prompt.description + == "Default prompt for the Home Assistant LLM API Stateless Assist" + ) async def test_prompt_get( @@ -383,13 +400,17 @@ async def test_prompt_get( """Test the get prompt endpoint.""" async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: - result = await session.get_prompt(name="Assist") + result = await session.get_prompt(name="Stateless Assist") - assert result.description == "Default prompt for the Home Assistant LLM API Assist" + assert ( + result.description + == "Default prompt for the Home Assistant LLM API Stateless Assist" + ) assert len(result.messages) == 1 assert result.messages[0].role == "assistant" assert result.messages[0].content.type == "text" assert "When controlling Home Assistant" in result.messages[0].content.text + assert result.messages[0].content.text.endswith(EXPECTED_PROMPT_SUFFIX) async def test_get_unknwon_prompt( From 5f28e95bdcceb20ea05ec92583e0b18dc2170581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 15:56:28 -0600 Subject: [PATCH 094/178] Bump habluetooth to 3.21.0 (#137129) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f43940821a1..ba60322c659 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.20.1" + "habluetooth==3.21.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3d3c43470f7..162524c38b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.20.1 +habluetooth==3.21.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index dd220389cde..fd0867cf531 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.20.1 +habluetooth==3.21.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71b839c6f8a..8ce732be037 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.20.1 +habluetooth==3.21.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 613168fd626d48f4b6e65001372950fccc22b647 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Feb 2025 20:02:10 -0600 Subject: [PATCH 095/178] Add missing brackets to ESPHome configuration URLs with IPv6 addresses (#137132) fixes #137125 --- homeassistant/components/esphome/manager.py | 4 ++- tests/components/esphome/test_manager.py | 36 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 218ea1c193d..5f5ee1241f7 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -573,7 +573,9 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: - configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" + entry_host = entry.data["host"] + host = f"[{entry_host}]" if ":" in entry_host else entry_host + configuration_url = f"http://{host}:{device_info.webserver_port}" elif ( (dashboard := async_get_dashboard(hass)) and dashboard.data diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 6fbd3726f64..7db1427d975 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1100,6 +1100,42 @@ async def test_esphome_device_with_web_server( assert dev.configuration_url == "http://test.local:80" +async def test_esphome_device_with_ipv6_web_server( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a web server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={}, + ) + entry.add_to_hass(hass) + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={"webserver_port": 80}, + states=[], + ) + await hass.async_block_till_done() + entry = device.entry + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.configuration_url == "http://[fe80::1]:80" + + async def test_esphome_device_with_compilation_time( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 78dcf8b18e99c0b8eaf4220f8a3054f575a683b2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 2 Feb 2025 03:02:34 +0100 Subject: [PATCH 096/178] Bump deebot-client to 12.0.0b0 (#137137) --- 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 16929e1741a..7b05162867b 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==11.1.0b2"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fd0867cf531..80a669f52b0 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==11.1.0b2 +deebot-client==12.0.0b0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ce732be037..0fc7bd400f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b2 +deebot-client==12.0.0b0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From dc1c2f24e62f31030540be22a87714e02ced5bac Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Feb 2025 02:06:10 +0000 Subject: [PATCH 097/178] Bump version to 2025.2.0b6 --- 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 806b896d20f..17f1084168a 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 = "0b5" +PATCH_VERSION: Final = "0b6" __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 4930723d125..5ac870ab0b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b5" +version = "2025.2.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From dbf9e370a8fe19e40e844cfbffed5def3763558d Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 3 Feb 2025 03:37:08 +1100 Subject: [PATCH 098/178] Allow manual smlight user setup to override discovery (#137136) Co-authored-by: J. Nick Koston --- .../components/smlight/config_flow.py | 12 +++--- tests/components/smlight/test_config_flow.py | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index dee81264fa4..34bd0758174 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -10,7 +10,7 @@ from pysmlight.const import Devices from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -36,10 +36,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" host: str - - def __init__(self) -> None: - """Initialize the config flow.""" - self.client: Api2 + client: Api2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -199,7 +196,10 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] ) -> ConfigFlowResult: info = await self.client.get_info() - await self.async_set_unique_id(format_mac(info.MAC)) + + await self.async_set_unique_id( + format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER + ) self._abort_if_unique_id_configured() if user_input.get(CONF_HOST) is None: diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c4aea195aa7..4dad06b0fa3 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -261,6 +261,44 @@ async def test_user_device_exists_abort( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_smlight_client") +async def test_user_flow_can_override_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test manual user flow can override discovery in progress.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["context"]["source"] == SOURCE_USER + assert result2["data"] == { + CONF_HOST: MOCK_HOST, + } + assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.usefixtures("mock_smlight_client") async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry From 505f089a73717e39da0ee94fb4dc11623faf0e06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 02:09:52 -0600 Subject: [PATCH 099/178] Bump dbus-fast to 2.30.4 (#137151) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.30.2...v2.30.4 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ba60322c659..1bb37554dec 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", - "dbus-fast==2.30.2", + "dbus-fast==2.30.4", "habluetooth==3.21.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 162524c38b0..95c49b3ba8c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.30.2 +dbus-fast==2.30.4 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 80a669f52b0..343d433d2ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.30.2 +dbus-fast==2.30.4 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fc7bd400f7..a5f7af2e12c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -631,7 +631,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.30.2 +dbus-fast==2.30.4 # homeassistant.components.debugpy debugpy==1.8.11 From 6c172705d1c5c3033865e4e50df3df9921a007c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 10:25:59 -0600 Subject: [PATCH 100/178] Bump bluetooth-data-tools to 1.23.3 (#137147) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1bb37554dec..eccde29174f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.8.0", "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.22.0", + "bluetooth-data-tools==1.23.3", "dbus-fast==2.30.4", "habluetooth==3.21.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 2e64a590eaf..a29a9834c9b 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.23.3", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 7b07653e2db..8608c0b2798 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.4"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 2ab736b02d3..90518c81483 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.22.0"] + "requirements": ["bluetooth-data-tools==1.23.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 95c49b3ba8c..0870a9daead 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.8.0 bleak==0.22.3 bluetooth-adapters==0.21.1 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.22.0 +bluetooth-data-tools==1.23.3 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 343d433d2ad..c776ee3c657 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -631,7 +631,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.22.0 +bluetooth-data-tools==1.23.3 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5f7af2e12c..8de8e228246 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -555,7 +555,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.22.0 +bluetooth-data-tools==1.23.3 # homeassistant.components.bond bond-async==0.2.1 From 7d1b72a581038bba86e1bbd7b65276cbc0cce9c3 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Feb 2025 09:08:14 +0100 Subject: [PATCH 101/178] Bump habiticalib to v0.3.4 (#137148) Bump habiticalib to version 0.3.4 --- .../components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/habitica/conftest.py | 4 +- .../habitica/snapshots/test_diagnostics.ambr | 6 +- .../habitica/snapshots/test_services.ambr | 87 ++++++++++++------- 6 files changed, 67 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 1c92c314e66..6ace6d45509 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.3"] + "requirements": ["habiticalib==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index c776ee3c657..8e2270d0f7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.3 +habiticalib==0.3.4 # homeassistant.components.bluetooth habluetooth==3.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8de8e228246..994fa923b44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.3 +habiticalib==0.3.4 # homeassistant.components.bluetooth habluetooth==3.21.0 diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index daf1c669463..e04fc58ad15 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -17,7 +17,7 @@ from habiticalib import ( HabiticaTaskOrderResponse, HabiticaTaskResponse, HabiticaTasksResponse, - HabiticaUserAnonymizedrResponse, + HabiticaUserAnonymizedResponse, HabiticaUserResponse, NotAuthorizedError, NotFoundError, @@ -140,7 +140,7 @@ async def mock_habiticalib() -> Generator[AsyncMock]: {"data": [], "success": True} ) client.get_user_anonymized.return_value = ( - HabiticaUserAnonymizedrResponse.from_json( + HabiticaUserAnonymizedResponse.from_json( load_fixture("anonymized.json", DOMAIN) ) ) diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index b4304e33ec8..1f3a14fade1 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -119,7 +119,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', 'isDue': None, 'nextDue': list([ @@ -190,7 +191,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', 'isDue': None, 'nextDue': list([ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index d0062212775..f40d50ded98 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -764,7 +764,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -837,7 +838,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -913,7 +915,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -984,7 +987,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -1056,7 +1060,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -1284,7 +1289,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -1356,7 +1362,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ @@ -1853,7 +1860,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -2996,7 +3004,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -3069,7 +3078,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -3145,7 +3155,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -3216,7 +3227,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -3288,7 +3300,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -3612,7 +3625,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ @@ -3690,7 +3704,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -5544,7 +5559,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -5621,7 +5637,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -5694,7 +5711,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -5770,7 +5788,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -5841,7 +5860,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -5913,7 +5933,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), 'isDue': None, 'nextDue': list([ @@ -5984,7 +6005,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -6056,7 +6078,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ @@ -6134,7 +6157,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), 'isDue': None, 'nextDue': list([ @@ -6207,7 +6231,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), 'isDue': None, 'nextDue': list([ @@ -6283,7 +6308,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), 'isDue': None, 'nextDue': list([ @@ -6354,7 +6380,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), 'isDue': None, 'nextDue': list([ @@ -6426,7 +6453,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), 'isDue': None, 'nextDue': list([ @@ -6498,7 +6526,8 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), 'isDue': None, 'nextDue': list([ From 63d1dddc7661ae90585088094a4864d5c712cd94 Mon Sep 17 00:00:00 2001 From: Jeef Date: Sun, 2 Feb 2025 06:11:44 -0700 Subject: [PATCH 102/178] Bump monarchmoney to 0.4.4 (#137168) feat: update to backing lib to update backing lib --- homeassistant/components/monarch_money/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json index ed28f825bcf..d45415bbcd7 100644 --- a/homeassistant/components/monarch_money/manifest.json +++ b/homeassistant/components/monarch_money/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/monarchmoney", "iot_class": "cloud_polling", - "requirements": ["typedmonarchmoney==0.3.1"] + "requirements": ["typedmonarchmoney==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e2270d0f7b..5ff4f5d5738 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2935,7 +2935,7 @@ twilio==6.32.0 twitchAPI==4.2.1 # homeassistant.components.monarch_money -typedmonarchmoney==0.3.1 +typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 994fa923b44..f3d8238a6e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2360,7 +2360,7 @@ twilio==6.32.0 twitchAPI==4.2.1 # homeassistant.components.monarch_money -typedmonarchmoney==0.3.1 +typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 From 54a718c1d7b5de10a633ac249ff436cfe6b86046 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Feb 2025 16:21:40 +0100 Subject: [PATCH 103/178] Fix mqtt reconfigure does not use broker entry password when it is not changed (#137169) --- homeassistant/components/mqtt/config_flow.py | 2 +- tests/components/mqtt/test_config_flow.py | 55 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a4d400dfea2..a9d417fc783 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -485,7 +485,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors, ): if is_reconfigure: - update_password_from_user_input( + validated_user_input = update_password_from_user_input( reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input ) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 072998f9b8d..1a4ca4bcf19 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2193,6 +2193,61 @@ async def test_reconfigure_flow_form( await hass.async_block_till_done(wait_background_tasks=True) +@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "test-broker", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: "mqtt-password", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_path", + } + ], +) +async def test_reconfigure_no_changed_password( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reconfigure flow.""" + await mqtt_mock_entry() + entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "10.10.10,10", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: PWD_NOT_CHANGED, + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}', + mqtt.CONF_WS_PATH: "/some_new_path", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + mqtt.CONF_BROKER: "10.10.10,10", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: "mqtt-password", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_new_path", + } + await hass.async_block_till_done(wait_background_tasks=True) + + @pytest.mark.parametrize( ( "version", From f1128adec4c8655470992266d4707ac3a381722b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 2 Feb 2025 15:19:31 +0000 Subject: [PATCH 104/178] Bump python-kasa to 0.10.1 (#137173) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 6f9eefbdabb..ff65211c9b3 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.10.0"] + "requirements": ["python-kasa[speedups]==0.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ff4f5d5738..3b3565335c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2406,7 +2406,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.0 +python-kasa[speedups]==0.10.1 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3d8238a6e6..bbe4e74884a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1945,7 +1945,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.0 +python-kasa[speedups]==0.10.1 # homeassistant.components.linkplay python-linkplay==0.1.3 From 48511986bbbf4875132df0893fca053b71ba51ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 11:10:24 -0600 Subject: [PATCH 105/178] Bump dbus-fast to 2.31.0 (#137180) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.30.4...v2.31.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index eccde29174f..cd2530e1717 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.3", - "dbus-fast==2.30.4", + "dbus-fast==2.31.0", "habluetooth==3.21.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0870a9daead..f5ffb862217 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.30.4 +dbus-fast==2.31.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 3b3565335c6..f2b4d0324c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.30.4 +dbus-fast==2.31.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbe4e74884a..c4fbd54b7e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -631,7 +631,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.30.4 +dbus-fast==2.31.0 # homeassistant.components.debugpy debugpy==1.8.11 From 433a51f6d51fa650d669465d8833a7af444286de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 13:17:58 -0600 Subject: [PATCH 106/178] Bump aiodhcpwatcher to 1.0.3 (#137188) changelog: https://github.com/bdraco/aiodhcpwatcher/compare/v1.0.2...v1.0.3 --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index ba773782e1c..0eb7e4a64fc 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -14,7 +14,7 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.0.2", + "aiodhcpwatcher==1.0.3", "aiodiscover==2.1.0", "cached-ipaddress==0.8.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5ffb862217..bc6002e72b8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.0.3 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 diff --git a/requirements_all.txt b/requirements_all.txt index f2b4d0324c6..628040a28ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.0.3 # homeassistant.components.dhcp aiodiscover==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4fbd54b7e1..0ba208ae419 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,7 +204,7 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.0.3 # homeassistant.components.dhcp aiodiscover==2.1.0 From 4fa043e6ffe9929624c7e4451d45ec4306b09851 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 20:22:49 -0600 Subject: [PATCH 107/178] Bump bleak-esphome to 2.7.0 (#137199) changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.6.0...v2.7.0 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 3a55730c60f..e7db70acf5c 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9585be72c63..1f8b505ec45 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.6.0" + "bleak-esphome==2.7.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 628040a28ac..eccebb088d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.6.0 +bleak-esphome==2.7.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ba208ae419..5be54ac4ada 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.6.0 +bleak-esphome==2.7.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 38975775ac6efe670860bfe44327ce4ebf534280 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 3 Feb 2025 10:08:32 +1100 Subject: [PATCH 108/178] Switch to using IP Addresses for connecting to smlight devices (#137204) --- .../components/smlight/config_flow.py | 53 +++++++---- .../components/smlight/manifest.json | 5 + homeassistant/generated/dhcp.py | 4 + tests/components/smlight/conftest.py | 3 +- .../smlight/snapshots/test_init.ambr | 2 +- tests/components/smlight/test_config_flow.py | 93 ++++++++++++++++--- 6 files changed, 128 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 34bd0758174..88ac3cde008 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResu from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -35,7 +36,8 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" - host: str + _host: str + _device_name: str client: Api2 async def async_step_user( @@ -45,11 +47,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - self.host = user_input[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = user_input[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) if info.model not in Devices: return self.async_abort(reason="unsupported_device") @@ -93,15 +97,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" - local_name = discovery_info.hostname[:-1] - node_name = local_name.removesuffix(".local") + mac: str | None = discovery_info.properties.get("mac") + self._device_name = discovery_info.hostname.removesuffix(".local.") + self._host = discovery_info.host - self.host = local_name - self.context["title_placeholders"] = {CONF_NAME: node_name} - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self.context["title_placeholders"] = {CONF_NAME: self._device_name} + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) - mac = discovery_info.properties.get("mac") - # fallback for legacy firmware + # fallback for legacy firmware older than v2.3.x if mac is None: try: info = await self.client.get_info() @@ -111,7 +114,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): mac = info.MAC await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) return await self.async_step_confirm_discovery() @@ -122,7 +125,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_HOST] = self.host try: info = await self.client.get_info() @@ -142,7 +144,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={"host": self.host}, + description_placeholders={"host": self._device_name}, errors=errors, ) @@ -151,8 +153,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" - self.host = entry_data[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = entry_data[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() @@ -182,6 +184,16 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + # This should never happen since we only listen to DHCP requests + # for configured devices. + return self.async_abort(reason="already_configured") + async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): @@ -200,11 +212,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER ) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) - if user_input.get(CONF_HOST) is None: - user_input[CONF_HOST] = self.host + user_input[CONF_HOST] = self._host assert info.model is not None - title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model + title = ( + self.context.get("title_placeholders", {}).get(CONF_NAME) + or self._device_name + or info.model + ) return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3a8578c8a59..63bae37f431 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -3,6 +3,11 @@ "name": "SMLIGHT SLZB", "codeowners": ["@tl-sl"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b9d51ac1006..3dba5a98f3c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -616,6 +616,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hub*", "macaddress": "286D97*", }, + { + "domain": "smlight", + "registered_devices": True, + }, { "domain": "solaredge", "hostname": "target", diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 665a55ba880..80e89e4eb16 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -18,7 +18,8 @@ from tests.common import ( load_json_object_fixture, ) -MOCK_HOST = "slzb-06.local" +MOCK_DEVICE_NAME = "slzb-06" +MOCK_HOST = "192.168.1.161" MOCK_USERNAME = "test-user" MOCK_PASSWORD = "test-pass" diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 598166e537b..457a529065c 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -3,7 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': 'http://slzb-06.local', + 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( 'mac', diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 4dad06b0fa3..a1c9c9d6945 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -8,19 +8,20 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest from homeassistant.components.smlight.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry DISCOVERY_INFO = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -29,8 +30,8 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -52,7 +53,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: MOCK_HOST, + CONF_HOST: "slzb-06p7.local", }, ) @@ -76,7 +77,7 @@ async def test_zeroconf_flow( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -113,7 +114,7 @@ async def test_zeroconf_flow_auth( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -167,7 +168,7 @@ async def test_zeroconf_unsupported_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -489,7 +490,7 @@ async def test_zeroconf_legacy_mac( data=DISCOVERY_INFO_LEGACY, ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -507,6 +508,76 @@ async def test_zeroconf_legacy_mac( assert len(mock_smlight_client.get_info.mock_calls) == 3 +@pytest.mark.usefixtures("mock_smlight_client") +async def test_zeroconf_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DISCOVERY_INFO + service_info.ip_address = ip_address("192.168.1.164") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.164", + hostname="slzb-06", + macaddress="aabbccddeeff", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.161", + hostname="slzb-06", + macaddress="000000000000", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.161" + + async def test_reauth_flow( hass: HomeAssistant, mock_smlight_client: MagicMock, From ddb40cb4a866f8cd49a57be749dab9161b48f88a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 20:22:58 -0600 Subject: [PATCH 109/178] Bump dbus-fast to 2.23.0 (#137205) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.31.0...v2.32.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cd2530e1717..22db886ef3f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.3", - "dbus-fast==2.31.0", + "dbus-fast==2.32.0", "habluetooth==3.21.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bc6002e72b8..949d1885511 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.31.0 +dbus-fast==2.32.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index eccebb088d0..48394b29dea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.31.0 +dbus-fast==2.32.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5be54ac4ada..230b22ac74c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -631,7 +631,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.31.0 +dbus-fast==2.32.0 # homeassistant.components.debugpy debugpy==1.8.11 From c8c6eddc652ad8ed1cf795f6d3b6777e06a63239 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 3 Feb 2025 10:46:27 +1100 Subject: [PATCH 110/178] Simplify config entry title for SMLIGHT (#137206) --- homeassistant/components/smlight/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 88ac3cde008..667e6e2884b 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -217,9 +217,5 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_HOST] = self._host assert info.model is not None - title = ( - self.context.get("title_placeholders", {}).get(CONF_NAME) - or self._device_name - or info.model - ) + title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) From 63c153d671e44a724114335ac6f023ce045b3d73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Feb 2025 02:27:53 +0000 Subject: [PATCH 111/178] Bump version to 2025.2.0b7 --- 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 17f1084168a..96613bf164a 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 = "0b6" +PATCH_VERSION: Final = "0b7" __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 5ac870ab0b2..1bbec0e596f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b6" +version = "2025.2.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 809f5eea4922bbd232e67a4dffdfb52cad25df02 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Mon, 3 Feb 2025 08:06:21 -0800 Subject: [PATCH 112/178] Bump todist-api-python to 2.1.7 (#136549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Allen Porter Co-authored-by: J. Diego Rodríguez Royo --- homeassistant/components/todoist/calendar.py | 5 ++--- homeassistant/components/todoist/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/todoist/conftest.py | 2 ++ 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 94581439ae9..8c61394d300 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -541,9 +541,8 @@ class TodoistProjectData: return None # All task Labels (optional parameter). - task[LABELS] = [ - label.name for label in self._labels if label.name in data.labels - ] + labels = data.labels or [] + task[LABELS] = [label.name for label in self._labels if label.name in labels] if self._label_whitelist and ( not any(label in task[LABELS] for label in self._label_whitelist) ): diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index 72d76108353..791f5642aad 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.1.2"] + "requirements": ["todoist-api-python==2.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48394b29dea..9616b4040a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2893,7 +2893,7 @@ tilt-ble==0.2.3 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.1.2 +todoist-api-python==2.1.7 # homeassistant.components.tolo tololib==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 230b22ac74c..81b567ad7df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ thinqconnect==1.0.2 tilt-ble==0.2.3 # homeassistant.components.todoist -todoist-api-python==2.1.2 +todoist-api-python==2.1.7 # homeassistant.components.tolo tololib==1.1.0 diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4b2bfea2e30..84f0fa740e9 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -70,6 +70,7 @@ def make_api_task( section_id=None, url="https://todoist.com", sync_id=None, + duration=None, ) @@ -94,6 +95,7 @@ def mock_api(tasks: list[Task]) -> AsyncMock: url="", is_inbox_project=False, is_team_inbox=False, + can_assign_tasks=False, order=1, parent_id=None, view_style="list", From 405cc4715744ccffca09569b447f9c855941e87b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:42:30 +0100 Subject: [PATCH 113/178] Don't blow up when a backup doesn't exist on Synology DSM (#136913) * don't raise while delte not existing backup * only raise when error ne 408 --- .../components/synology_dsm/backup.py | 23 ++++++----- tests/components/synology_dsm/test_backup.py | 39 ++++++++++++++----- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 62a1b97b717..5f3312717ef 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -161,15 +161,20 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - try: - await self._file_station.delete_file( - path=self.path, filename=f"{backup_id}.tar" - ) - await self._file_station.delete_file( - path=self.path, filename=f"{backup_id}_meta.json" - ) - except SynologyDSMAPIErrorException as err: - raise BackupAgentError("Failed to delete the backup") from err + for filename in (f"{backup_id}.tar", f"{backup_id}_meta.json"): + try: + await self._file_station.delete_file(path=self.path, filename=filename) + except SynologyDSMAPIErrorException as err: + err_args: dict = err.args[0] + if int(err_args.get("code", 0)) != 900 or ( + (err_details := err_args.get("details")) is not None + and isinstance(err_details, list) + and isinstance(err_details[0], dict) + and int(err_details[0].get("code", 0)) + != 408 # No such file or directory + ): + LOGGER.error("Failed to delete backup: %s", err) + raise BackupAgentError("Failed to delete backup") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index cdbc5934c5f..bcd9f1aa4eb 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -673,7 +673,11 @@ async def test_agents_delete_not_existing( backup_id = "ef34ab12" setup_dsm_with_filestation.file.delete_file = AsyncMock( - side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + side_effect=SynologyDSMAPIErrorException( + "api", + "900", + [{"code": 408, "path": f"/ha_backup/my_backup_path/{backup_id}.tar"}], + ) ) await client.send_json_auto_id( @@ -685,26 +689,40 @@ async def test_agents_delete_not_existing( response = await client.receive_json() assert response["success"] - assert response["result"] == { - "agent_errors": { - "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" - } - } + assert response["result"] == {"agent_errors": {}} +@pytest.mark.parametrize( + ("error", "expected_log"), + [ + ( + SynologyDSMAPIErrorException("api", "100", "Unknown error"), + "{'api': 'api', 'code': '100', 'reason': 'Unknown', 'details': 'Unknown error'}", + ), + ( + SynologyDSMAPIErrorException("api", "900", [{"code": 407}]), + "{'api': 'api', 'code': '900', 'reason': 'Unknown', 'details': [{'code': 407}]", + ), + ( + SynologyDSMAPIErrorException("api", "900", [{"code": 417}]), + "{'api': 'api', 'code': '900', 'reason': 'Unknown', 'details': [{'code': 417}]", + ), + ], +) async def test_agents_delete_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, setup_dsm_with_filestation: MagicMock, + error: SynologyDSMAPIErrorException, + expected_log: str, ) -> None: """Test error while delete backup.""" client = await hass_ws_client(hass) # error while delete backup_id = "abcd12ef" - setup_dsm_with_filestation.file.delete_file.side_effect = ( - SynologyDSMAPIErrorException("api", "404", "not found") - ) + setup_dsm_with_filestation.file.delete_file.side_effect = error await client.send_json_auto_id( { "type": "backup/delete", @@ -716,9 +734,10 @@ async def test_agents_delete_error( assert response["success"] assert response["result"] == { "agent_errors": { - "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete backup" } } + assert f"Failed to delete backup: {expected_log}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" From 81783dcfd3153e9a3ca6b18307ec50dff730122e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 3 Feb 2025 16:25:58 +0100 Subject: [PATCH 114/178] Migrate OneDrive to onedrive_personal_sdk library (#137064) --- homeassistant/components/onedrive/__init__.py | 123 +++------ homeassistant/components/onedrive/api.py | 35 +-- homeassistant/components/onedrive/backup.py | 238 +++++----------- .../components/onedrive/config_flow.py | 56 ++-- .../components/onedrive/manifest.json | 4 +- .../components/onedrive/strings.json | 15 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/onedrive/conftest.py | 119 ++------ tests/components/onedrive/const.py | 58 ++++ tests/components/onedrive/test_backup.py | 255 ++++-------------- tests/components/onedrive/test_config_flow.py | 49 +++- tests/components/onedrive/test_init.py | 67 +---- 13 files changed, 307 insertions(+), 724 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4ae5ac73560..ef7ddd04da6 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -5,34 +5,33 @@ from __future__ import annotations from dataclasses import dataclass import logging -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider -from msgraph import GraphRequestAdapter, GraphServiceClient -from msgraph.generated.drives.item.items.items_request_builder import ( - ItemsRequestBuilder, +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HttpRequestException, + OneDriveException, ) -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.folder import Folder from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.instance_id import async_get as async_get_instance_id from .api import OneDriveConfigEntryAccessTokenProvider -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @dataclass class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" - items: ItemsRequestBuilder + client: OneDriveClient + token_provider: OneDriveConfigEntryAccessTokenProvider backup_folder_id: str @@ -47,29 +46,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - auth_provider = BaseBearerTokenAuthenticationProvider( - access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) - ) - adapter = GraphRequestAdapter( - auth_provider=auth_provider, - client=create_async_httpx_client(hass, follow_redirects=True), - ) + token_provider = OneDriveConfigEntryAccessTokenProvider(session) - graph_client = GraphServiceClient( - request_adapter=adapter, - scopes=OAUTH_SCOPES, - ) - assert entry.unique_id - drive_item = graph_client.drives.by_drive_id(entry.unique_id) + client = OneDriveClient(token_provider, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: - approot = await drive_item.special.by_drive_item_id("approot").get() - except APIError as err: - if err.response_status_code == 403: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err + approot = await client.get_approot() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (HttpRequestException, OneDriveException, TimeoutError) as err: _LOGGER.debug("Failed to get approot", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -77,24 +65,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_placeholders={"folder": "approot"}, ) from err - if approot is None or not approot.id: - _LOGGER.debug("Failed to get approot, was None") + instance_id = await async_get_instance_id(hass) + backup_folder_name = f"backups_{instance_id[:8]}" + try: + backup_folder = await client.create_folder( + parent_id=approot.id, name=backup_folder_name + ) + except (HttpRequestException, OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to create backup folder", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="failed_to_get_folder", - translation_placeholders={"folder": "approot"}, - ) - - instance_id = await async_get_instance_id(hass) - backup_folder_id = await _async_create_folder_if_not_exists( - items=drive_item.items, - base_folder_id=approot.id, - folder=f"backups_{instance_id[:8]}", - ) + translation_placeholders={"folder": backup_folder_name}, + ) from err entry.runtime_data = OneDriveRuntimeData( - items=drive_item.items, - backup_folder_id=backup_folder_id, + client=client, + token_provider=token_provider, + backup_folder_id=backup_folder.id, ) _async_notify_backup_listeners_soon(hass) @@ -116,54 +104,3 @@ def _async_notify_backup_listeners(hass: HomeAssistant) -> None: @callback def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: hass.loop.call_soon(_async_notify_backup_listeners, hass) - - -async def _async_create_folder_if_not_exists( - items: ItemsRequestBuilder, - base_folder_id: str, - folder: str, -) -> str: - """Check if a folder exists and create it if it does not exist.""" - folder_item: DriveItem | None = None - - try: - folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() - except APIError as err: - if err.response_status_code != 404: - _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": folder}, - ) from err - # is 404 not found, create folder - _LOGGER.debug("Creating folder %s", folder) - request_body = DriveItem( - name=folder, - folder=Folder(), - additional_data={ - "@microsoft_graph_conflict_behavior": "fail", - }, - ) - try: - folder_item = await items.by_drive_item_id(base_folder_id).children.post( - request_body - ) - except APIError as create_err: - _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_create_folder", - translation_placeholders={"folder": folder}, - ) from create_err - _LOGGER.debug("Created folder %s", folder) - else: - _LOGGER.debug("Found folder %s", folder) - if folder_item is None or not folder_item.id: - _LOGGER.debug("Failed to get folder %s, was None", folder) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": folder}, - ) - return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py index 934a4f74ec9..d8f6ea188f3 100644 --- a/homeassistant/components/onedrive/api.py +++ b/homeassistant/components/onedrive/api.py @@ -1,28 +1,14 @@ """API for OneDrive bound to Home Assistant OAuth.""" -from typing import Any, cast +from typing import cast -from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator +from onedrive_personal_sdk import TokenProvider from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import config_entry_oauth2_flow -class OneDriveAccessTokenProvider(AccessTokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self) -> None: - """Initialize OneDrive auth.""" - super().__init__() - # currently allowing all hosts - self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) - - def get_allowed_hosts_validator(self) -> AllowedHostsValidator: - """Retrieve the allowed hosts validator.""" - return self._allowed_hosts_validator - - -class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): +class OneDriveConfigFlowAccessTokenProvider(TokenProvider): """Provide OneDrive authentication tied to an OAuth2 based config entry.""" def __init__(self, token: str) -> None: @@ -30,14 +16,12 @@ class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): super().__init__() self._token = token - async def get_authorization_token( # pylint: disable=dangerous-default-value - self, uri: str, additional_authentication_context: dict[str, Any] = {} - ) -> str: - """Return a valid authorization token.""" + def async_get_access_token(self) -> str: + """Return a valid access token.""" return self._token -class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): +class OneDriveConfigEntryAccessTokenProvider(TokenProvider): """Provide OneDrive authentication tied to an OAuth2 based config entry.""" def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: @@ -45,9 +29,6 @@ class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): super().__init__() self._oauth_session = oauth_session - async def get_authorization_token( # pylint: disable=dangerous-default-value - self, uri: str, additional_authentication_context: dict[str, Any] = {} - ) -> str: - """Return a valid authorization token.""" - await self._oauth_session.async_ensure_token_valid() + def async_get_access_token(self) -> str: + """Return a valid access token.""" return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a7bac5d01fc..43eac020538 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,37 +2,22 @@ from __future__ import annotations -import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html import json import logging -from typing import Any, Concatenate, cast +from typing import Any, Concatenate -from httpx import Response, TimeoutException -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import AnonymousAuthenticationProvider -from kiota_abstractions.headers_collection import HeadersCollection -from kiota_abstractions.method import Method -from kiota_abstractions.native_response_handler import NativeResponseHandler -from kiota_abstractions.request_information import RequestInformation -from kiota_http.middleware.options import ResponseHandlerOption -from msgraph import GraphRequestAdapter -from msgraph.generated.drives.item.items.item.content.content_request_builder import ( - ContentRequestBuilder, +from aiohttp import ClientTimeout +from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, ) -from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( - CreateUploadSessionPostRequestBody, -) -from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( - DriveItemItemRequestBuilder, -) -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.drive_item_uploadable_properties import ( - DriveItemUploadableProperties, -) -from msgraph_core.models import LargeFileUploadSession +from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate +from onedrive_personal_sdk.models.upload import FileInfo from homeassistant.components.backup import ( AgentBackup, @@ -41,14 +26,14 @@ from homeassistant.components.backup import ( suggested_filename, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import OneDriveConfigEntry from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB -MAX_RETRIES = 5 +TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours async def async_get_backup_agents( @@ -92,18 +77,18 @@ def handle_backup_errors[_R, **P]( ) -> _R: try: return await func(self, *args, **kwargs) - except APIError as err: - if err.response_status_code == 403: - self._entry.async_start_reauth(self._hass) + except AuthenticationError as err: + self._entry.async_start_reauth(self._hass) + raise BackupAgentError("Authentication error") from err + except OneDriveException as err: _LOGGER.error( - "Error during backup in %s: Status %s, message %s", + "Error during backup in %s:, message %s", func.__name__, - err.response_status_code, - err.message, + err, ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutException as err: + except TimeoutError as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -123,7 +108,8 @@ class OneDriveBackupAgent(BackupAgent): super().__init__() self._hass = hass self._entry = entry - self._items = entry.runtime_data.items + self._client = entry.runtime_data.client + self._token_provider = entry.runtime_data.token_provider self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -134,24 +120,12 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AsyncIterator[bytes]: """Download a backup file.""" - # this forces the query to return a raw httpx response, but breaks typing - backup = await self._find_item_by_backup_id(backup_id) - if backup is None or backup.id is None: + item = await self._find_item_by_backup_id(backup_id) + if item is None: raise BackupAgentError("Backup not found") - request_config = ( - ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( - options=[ResponseHandlerOption(NativeResponseHandler())], - ) - ) - response = cast( - Response, - await self._items.by_drive_item_id(backup.id).content.get( - request_configuration=request_config - ), - ) - - return response.aiter_bytes(chunk_size=1024) + stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT) + return stream.iter_chunked(1024) @handle_backup_errors async def async_upload_backup( @@ -163,27 +137,20 @@ class OneDriveBackupAgent(BackupAgent): ) -> None: """Upload a backup.""" - # upload file in chunks to support large files - upload_session_request_body = CreateUploadSessionPostRequestBody( - item=DriveItemUploadableProperties( - additional_data={ - "@microsoft.graph.conflictBehavior": "fail", - }, + file = FileInfo( + suggested_filename(backup), + backup.size, + self._folder_id, + await open_stream(), + ) + try: + item = await LargeFileUploadClient.upload( + self._token_provider, file, session=async_get_clientsession(self._hass) ) - ) - file_item = self._get_backup_file_item(suggested_filename(backup)) - upload_session = await file_item.create_upload_session.post( - upload_session_request_body - ) - - if upload_session is None or upload_session.upload_url is None: + except HashMismatchError as err: raise BackupAgentError( - translation_domain=DOMAIN, translation_key="backup_no_upload_session" - ) - - await self._upload_file( - upload_session.upload_url, await open_stream(), backup.size - ) + "Hash validation failed, backup file might be corrupt" + ) from err # store metadata in description backup_dict = backup.as_dict() @@ -191,7 +158,10 @@ class OneDriveBackupAgent(BackupAgent): description = json.dumps(backup_dict) _LOGGER.debug("Creating metadata: %s", description) - await file_item.patch(DriveItem(description=description)) + await self._client.update_drive_item( + path_or_id=item.id, + data=ItemUpdate(description=description), + ) @handle_backup_errors async def async_delete_backup( @@ -200,35 +170,31 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - backup = await self._find_item_by_backup_id(backup_id) - if backup is None or backup.id is None: + item = await self._find_item_by_backup_id(backup_id) + if item is None: return - await self._items.by_drive_item_id(backup.id).delete() + await self._client.delete_drive_item(item.id) @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - backups: list[AgentBackup] = [] - items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() - if items and (values := items.value): - for item in values: - if (description := item.description) is None: - continue - if "homeassistant_version" in description: - backups.append(self._backup_from_description(description)) - return backups + return [ + self._backup_from_description(item.description) + for item in await self._client.list_drive_items(self._folder_id) + if item.description and "homeassistant_version" in item.description + ] @handle_backup_errors async def async_get_backup( self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - backup = await self._find_item_by_backup_id(backup_id) - if backup is None: - return None - - assert backup.description # already checked in _find_item_by_backup_id - return self._backup_from_description(backup.description) + item = await self._find_item_by_backup_id(backup_id) + return ( + self._backup_from_description(item.description) + if item and item.description + else None + ) def _backup_from_description(self, description: str) -> AgentBackup: """Create a backup object from a description.""" @@ -237,91 +203,13 @@ class OneDriveBackupAgent(BackupAgent): ) # OneDrive encodes the description on save automatically return AgentBackup.from_dict(json.loads(description)) - async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None: - """Find a backup item by its backup ID.""" - - items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() - if items and (values := items.value): - for item in values: - if (description := item.description) is None: - continue - if backup_id in description: - return item - return None - - def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: - return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:") - - async def _upload_file( - self, upload_url: str, stream: AsyncIterator[bytes], total_size: int - ) -> None: - """Use custom large file upload; SDK does not support stream.""" - - adapter = GraphRequestAdapter( - auth_provider=AnonymousAuthenticationProvider(), - client=get_async_client(self._hass), + async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None: + """Find an item by backup ID.""" + return next( + ( + item + for item in await self._client.list_drive_items(self._folder_id) + if item.description and backup_id in item.description + ), + None, ) - - async def async_upload( - start: int, end: int, chunk_data: bytes - ) -> LargeFileUploadSession: - info = RequestInformation() - info.url = upload_url - info.http_method = Method.PUT - info.headers = HeadersCollection() - info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") - info.headers.try_add("Content-Length", str(len(chunk_data))) - info.headers.try_add("Content-Type", "application/octet-stream") - _LOGGER.debug(info.headers.get_all()) - info.set_stream_content(chunk_data) - result = await adapter.send_async(info, LargeFileUploadSession, {}) - _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) - return result - - start = 0 - buffer: list[bytes] = [] - buffer_size = 0 - retries = 0 - - async for chunk in stream: - buffer.append(chunk) - buffer_size += len(chunk) - if buffer_size >= UPLOAD_CHUNK_SIZE: - chunk_data = b"".join(buffer) - uploaded_chunks = 0 - while ( - buffer_size > UPLOAD_CHUNK_SIZE - ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 - slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - try: - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) - except APIError as err: - if ( - err.response_status_code and err.response_status_code < 500 - ): # no retry on 4xx errors - raise - if retries < MAX_RETRIES: - await asyncio.sleep(2**retries) - retries += 1 - continue - raise - except TimeoutException: - if retries < MAX_RETRIES: - retries += 1 - continue - raise - retries = 0 - start += UPLOAD_CHUNK_SIZE - uploaded_chunks += 1 - buffer_size -= UPLOAD_CHUNK_SIZE - buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] - - # upload the remaining bytes - if buffer: - _LOGGER.debug("Last chunk") - chunk_data = b"".join(buffer) - await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 09c0d1b44cc..cbdf59648b9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -4,16 +4,13 @@ from collections.abc import Mapping import logging from typing import Any, cast -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider -from kiota_abstractions.method import Method -from kiota_abstractions.request_information import RequestInformation -from msgraph import GraphRequestAdapter, GraphServiceClient +from onedrive_personal_sdk.clients.client import OneDriveClient +from onedrive_personal_sdk.exceptions import OneDriveException from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from homeassistant.helpers.httpx_client import get_async_client from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -39,48 +36,24 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - auth_provider = BaseBearerTokenAuthenticationProvider( - access_token_provider=OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) - ) - adapter = GraphRequestAdapter( - auth_provider=auth_provider, - client=get_async_client(self.hass), + token_provider = OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) ) - graph_client = GraphServiceClient( - request_adapter=adapter, - scopes=OAUTH_SCOPES, + graph_client = OneDriveClient( + token_provider, async_get_clientsession(self.hass) ) - # need to get adapter from client, as client changes it - request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) - - request_info = RequestInformation( - method=Method.GET, - url_template="{+baseurl}/me/drive/special/approot", - path_parameters={}, - ) - parent_span = request_adapter.start_tracing_span(request_info, "get_approot") - - # get the OneDrive id - # use low level methods, to avoid files.read permissions - # which would be required by drives.me.get() try: - response = await request_adapter.get_http_response_message( - request_info=request_info, parent_span=parent_span - ) - except APIError: + approot = await graph_client.get_approot() + except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") except Exception: self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive: dict = response.json() - - await self.async_set_unique_id(drive["parentReference"]["driveId"]) + await self.async_set_unique_id(approot.parent_reference.drive_id) if self.source == SOURCE_REAUTH: reauth_entry = self._get_reauth_entry() @@ -94,10 +67,11 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - user = drive.get("createdBy", {}).get("user", {}).get("displayName") - - title = f"{user}'s OneDrive" if user else "OneDrive" - + title = ( + f"{approot.created_by.user.display_name}'s OneDrive" + if approot.created_by.user and approot.created_by.user.display_name + else "OneDrive" + ) return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 056e31864a4..767426058c1 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/onedrive", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["msgraph", "msgraph-core", "kiota"], + "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["msgraph-sdk==1.16.0"] + "requirements": ["onedrive-personal-sdk==0.0.1"] } diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 9cbdb2bdeae..7686e83e2a5 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -23,31 +23,18 @@ "connection_error": "Failed to connect to OneDrive.", "wrong_drive": "New account does not contain previously configured OneDrive.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "failed_to_create_folder": "Failed to create backup folder" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, "exceptions": { - "backup_not_found": { - "message": "Backup not found" - }, - "backup_no_content": { - "message": "Backup has no content" - }, - "backup_no_upload_session": { - "message": "Failed to start backup upload" - }, "authentication_failed": { "message": "Authentication failed" }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" - }, - "failed_to_create_folder": { - "message": "Failed to create {folder} folder" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 9616b4040a1..dc0db578cba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1434,9 +1434,6 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 -# homeassistant.components.onedrive -msgraph-sdk==1.16.0 - # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1558,6 +1555,9 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.1 + # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81b567ad7df..92c8538c966 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1206,9 +1206,6 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 -# homeassistant.components.onedrive -msgraph-sdk==1.16.0 - # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1306,6 +1303,9 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.1 + # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 205f5837ee7..e76ce1d01c8 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,18 +1,9 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator -from html import escape -from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch -from httpx import Response -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.drive_item_collection_response import ( - DriveItemCollectionResponse, -) -from msgraph.generated.models.upload_session import UploadSession -from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.application_credentials import ( @@ -23,7 +14,13 @@ from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + MOCK_APPROOT, + MOCK_BACKUP_FILE, + MOCK_BACKUP_FOLDER, +) from tests.common import MockConfigEntry @@ -70,96 +67,41 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture -def mock_adapter() -> Generator[MagicMock]: - """Return a mocked GraphAdapter.""" - with ( - patch( - "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", - autospec=True, - ) as mock_adapter, - patch( - "homeassistant.components.onedrive.backup.GraphRequestAdapter", - new=mock_adapter, - ), - ): - adapter = mock_adapter.return_value - adapter.get_http_response_message.return_value = Response( - status_code=200, - json={ - "parentReference": {"driveId": "mock_drive_id"}, - "createdBy": {"user": {"displayName": "John Doe"}}, - }, - ) - yield adapter - adapter.send_async.return_value = LargeFileUploadSession( - next_expected_ranges=["2-"] - ) - - @pytest.fixture(autouse=True) -def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: +def mock_onedrive_client() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( - "homeassistant.components.onedrive.config_flow.GraphServiceClient", + "homeassistant.components.onedrive.config_flow.OneDriveClient", autospec=True, - ) as graph_client, + ) as onedrive_client, patch( - "homeassistant.components.onedrive.GraphServiceClient", - new=graph_client, + "homeassistant.components.onedrive.OneDriveClient", + new=onedrive_client, ), ): - client = graph_client.return_value + client = onedrive_client.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - client.request_adapter = mock_adapter + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" - drives = client.drives.by_drive_id.return_value - drives.special.by_drive_item_id.return_value.get = AsyncMock( - return_value=DriveItem(id="approot") - ) - - drive_items = drives.items.by_drive_item_id.return_value - drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) - drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) - drive_items.children.get = AsyncMock( - return_value=DriveItemCollectionResponse( - value=[ - DriveItem( - id=BACKUP_METADATA["backup_id"], - description=escape(dumps(BACKUP_METADATA)), - ), - DriveItem(), - ] - ) - ) - drive_items.delete = AsyncMock(return_value=None) - drive_items.create_upload_session.post = AsyncMock( - return_value=UploadSession(upload_url="https://test.tld") - ) - drive_items.patch = AsyncMock(return_value=None) - - async def generate_bytes() -> AsyncIterator[bytes]: - """Asynchronous generator that yields bytes.""" - yield b"backup data" - - drive_items.content.get = AsyncMock( - return_value=Response(status_code=200, content=generate_bytes()) - ) + client.download_drive_item.return_value = MockStreamReader() yield client @pytest.fixture -def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: - """Return a mocked DriveItems.""" - return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value - - -@pytest.fixture -def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: - """Mock the get special folder method.""" - return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get +def mock_large_file_upload_client() -> Generator[AsyncMock]: + """Return a mocked LargeFileUploadClient upload.""" + with patch( + "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" + ) as mock_upload: + yield mock_upload @pytest.fixture @@ -179,10 +121,3 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield - - -@pytest.fixture(autouse=True) -def mock_asyncio_sleep() -> Generator[AsyncMock]: - """Mock asyncio.sleep.""" - with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): - yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index c187feef30a..ee3a5ce3dc4 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -1,5 +1,18 @@ """Consts for OneDrive tests.""" +from html import escape +from json import dumps + +from onedrive_personal_sdk.models.items import ( + AppRoot, + Contributor, + File, + Folder, + Hashes, + ItemParentReference, + User, +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -17,3 +30,48 @@ BACKUP_METADATA = { "protected": False, "size": 34519040, } + +CONTRIBUTOR = Contributor( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) +) + +MOCK_APPROOT = AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=CONTRIBUTOR, +) + +MOCK_BACKUP_FOLDER = Folder( + id="id", + name="name", + size=0, + child_count=0, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=CONTRIBUTOR, +) + +MOCK_BACKUP_FILE = File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape(dumps(BACKUP_METADATA)), + created_by=CONTRIBUTOR, +) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 0114d924e1a..3f8c29efa7e 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -3,15 +3,14 @@ from __future__ import annotations from collections.abc import AsyncGenerator -from html import escape from io import StringIO -from json import dumps from unittest.mock import Mock, patch -from httpx import TimeoutException -from kiota_abstractions.api_error import APIError -from msgraph.generated.models.drive_item import DriveItem -from msgraph_core.models import LargeFileUploadSession +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, +) import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -102,14 +101,10 @@ async def test_agents_list_backups( async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test agent get backup.""" - mock_drive_items.get = AsyncMock( - return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) - ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -140,7 +135,7 @@ async def test_agents_get_backup( async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test agent delete backup.""" client = await hass_ws_client(hass) @@ -155,37 +150,15 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_drive_items.delete.assert_called_once() - - -async def test_agents_delete_not_found_does_not_throw( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, -) -> None: - """Test agent delete backup.""" - mock_drive_items.children.get = AsyncMock(return_value=[]) - client = await hass_ws_client(hass) - - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": BACKUP_METADATA["backup_id"], - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - assert mock_drive_items.delete.call_count == 0 + mock_onedrive_client.delete_drive_item.assert_called_once() async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -200,7 +173,6 @@ async def test_agents_upload( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup @@ -211,31 +183,22 @@ async def test_agents_upload( assert resp.status == 201 assert f"Uploading backup {test_backup.backup_id}" in caplog.text - mock_drive_items.create_upload_session.post.assert_called_once() - mock_drive_items.patch.assert_called_once() - assert mock_adapter.send_async.call_count == 2 - assert mock_adapter.method_calls[0].args[0].content == b"tes" - assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { - "bytes 0-2/34519040" - } - assert mock_adapter.method_calls[1].args[0].content == b"t" - assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { - "bytes 3-3/34519040" - } + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.update_drive_item.assert_called_once() -async def test_broken_upload_session( +async def test_agents_upload_corrupt_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test broken upload session.""" + """Test hash validation fails.""" + mock_large_file_upload_client.side_effect = HashMismatchError("test") client = await hass_client() test_backup = AgentBackup.from_dict(BACKUP_METADATA) - mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -254,152 +217,18 @@ async def test_broken_upload_session( ) assert resp.status == 201 - assert "Failed to start backup upload" in caplog.text - - -@pytest.mark.parametrize( - "side_effect", - [ - APIError(response_status_code=500), - TimeoutException("Timeout"), - ], -) -async def test_agents_upload_errors_retried( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, - side_effect: Exception, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = [ - side_effect, - LargeFileUploadSession(next_expected_ranges=["2-"]), - LargeFileUploadSession(next_expected_ranges=["2-"]), - ] - - 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.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 3 assert f"Uploading backup {test_backup.backup_id}" in caplog.text - mock_drive_items.patch.assert_called_once() - - -async def test_agents_upload_4xx_errors_not_retried( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = APIError(response_status_code=404) - - 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.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 1 - assert f"Uploading backup {test_backup.backup_id}" in caplog.text - assert mock_drive_items.patch.call_count == 0 - assert "Backup operation failed" in caplog.text - - -@pytest.mark.parametrize( - ("side_effect", "error"), - [ - (APIError(response_status_code=500), "Backup operation failed"), - (TimeoutException("Timeout"), "Backup operation timed out"), - ], -) -async def test_agents_upload_fails_after_max_retries( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, - side_effect: Exception, - error: str, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.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, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 6 - assert f"Uploading backup {test_backup.backup_id}" in caplog.text - assert mock_drive_items.patch.call_count == 0 - assert error in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + assert "Hash validation failed, backup file might be corrupt" in caplog.text async def test_agents_download( hass_client: ClientSessionGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test agent download backup.""" - mock_drive_items.get = AsyncMock( - return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) - ) client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] @@ -408,29 +237,30 @@ async def test_agents_download( ) assert resp.status == 200 assert await resp.content.read() == b"backup data" - mock_drive_items.content.get.assert_called_once() @pytest.mark.parametrize( ("side_effect", "error"), [ ( - APIError(response_status_code=500), + OneDriveException(), "Backup operation failed", ), - (TimeoutException("Timeout"), "Backup operation timed out"), + (TimeoutError(), "Backup operation timed out"), ], ) async def test_delete_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, side_effect: Exception, error: str, ) -> None: """Test error during delete.""" - mock_drive_items.delete = AsyncMock(side_effect=side_effect) + mock_onedrive_client.delete_drive_item.side_effect = AsyncMock( + side_effect=side_effect + ) client = await hass_ws_client(hass) @@ -448,14 +278,35 @@ async def test_delete_error( } +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_onedrive_client.list_drive_items.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + async def test_agents_backup_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test backup not found.""" - mock_drive_items.children.get = AsyncMock(return_value=[]) + mock_onedrive_client.list_drive_items.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -468,13 +319,13 @@ async def test_agents_backup_not_found( async def test_reauth_on_403( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we re-authenticate on 403.""" - mock_drive_items.children.get = AsyncMock( - side_effect=APIError(response_status_code=403) + mock_onedrive_client.list_drive_items.side_effect = AuthenticationError( + 403, "Auth failed" ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) @@ -483,7 +334,7 @@ async def test_reauth_on_403( assert response["success"] assert response["result"]["agent_errors"] == { - f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + f"{DOMAIN}.{mock_config_entry.unique_id}": "Authentication error" } await hass.async_block_till_done() diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 8be6aadfd0f..9acfd8ada3c 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -3,8 +3,7 @@ from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock -from httpx import Response -from kiota_abstractions.api_error import APIError +from onedrive_personal_sdk.exceptions import OneDriveException import pytest from homeassistant import config_entries @@ -20,7 +19,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import setup_integration -from .const import CLIENT_ID +from .const import CLIENT_ID, MOCK_APPROOT from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -89,25 +88,52 @@ async def test_full_flow( assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow_with_owner_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we get a default title if the drive's owner can't be read.""" + + mock_onedrive_client.get_approot.return_value.created_by.user = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.parametrize( ("exception", "error"), [ (Exception, "unknown"), - (APIError, "connection_error"), + (OneDriveException, "connection_error"), ], ) async def test_flow_errors( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_adapter: MagicMock, + mock_onedrive_client: MagicMock, exception: Exception, error: str, ) -> None: """Test errors during flow.""" - mock_adapter.get_http_response_message.side_effect = exception + mock_onedrive_client.get_approot.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -172,15 +198,12 @@ async def test_reauth_flow_id_changed( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test that the reauth flow fails on a different drive id.""" - mock_adapter.get_http_response_message.return_value = Response( - status_code=200, - json={ - "parentReference": {"driveId": "other_drive_id"}, - }, - ) + app_root = MOCK_APPROOT + app_root.parent_reference.drive_id = "other_drive_id" + mock_onedrive_client.get_approot.return_value = app_root await setup_integration(hass, mock_config_entry) diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index bc5c22c3ce6..674bc2d38d9 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from kiota_abstractions.api_error import APIError +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest from homeassistant.config_entries import ConfigEntryState @@ -31,82 +31,31 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize( ("side_effect", "state"), [ - (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), - (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + (AuthenticationError(403, "Auth failed"), ConfigEntryState.SETUP_ERROR), + (OneDriveException(), ConfigEntryState.SETUP_RETRY), ], ) async def test_approot_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_special_folder: MagicMock, + mock_onedrive_client: MagicMock, side_effect: Exception, state: ConfigEntryState, ) -> None: """Test errors during approot retrieval.""" - mock_get_special_folder.side_effect = side_effect + mock_onedrive_client.get_approot.side_effect = side_effect await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is state -async def test_faulty_approot( +async def test_get_integration_folder_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_special_folder: MagicMock, + mock_onedrive_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test faulty approot retrieval.""" - mock_get_special_folder.return_value = None - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get approot folder" in caplog.text - - -async def test_faulty_integration_folder( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test faulty approot retrieval.""" - mock_drive_items.get.return_value = None + mock_onedrive_client.create_folder.side_effect = OneDriveException() await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert "Failed to get backups_9f86d081 folder" in caplog.text - - -async def test_500_error_during_backup_folder_get( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error during backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=500) - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get backups_9f86d081 folder" in caplog.text - - -async def test_error_during_backup_folder_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error during backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=404) - mock_drive_items.children.post.side_effect = APIError() - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to create backups_9f86d081 folder" in caplog.text - - -async def test_successful_backup_folder_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, -) -> None: - """Test successful backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=404) - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.LOADED From 90ddb6cce1fca9cac89deebc6f77241e709c55ff Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 4 Feb 2025 05:46:14 -0600 Subject: [PATCH 115/178] Humidifier turn display off for sleep mode (#137133) --- homeassistant/components/vesync/humidifier.py | 6 ++- tests/components/vesync/test_humidifier.py | 44 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 86e0d6b5d87..3bae838196f 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -155,11 +155,15 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): """Set the mode of the device.""" if mode not in self.available_modes: raise HomeAssistantError( - "{mode} is not one of the valid available modes: {self.available_modes}" + f"{mode} is not one of the valid available modes: {self.available_modes}" ) if not self.device.set_humidity_mode(self._get_vs_mode(mode)): raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + if mode == MODE_SLEEP: + # We successfully changed the mode. Consider it a success even if display operation fails. + self.device.set_display(False) + # Changing mode while humidifier is off actually turns it on, as per the app. But # the library does not seem to update the device_status. It is also possible that # other attributes get updated. Scheduling a forced refresh to get device status. diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index b93c97baab6..d5057c44951 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -10,9 +10,16 @@ from homeassistant.components.humidifier import ( ATTR_HUMIDITY, ATTR_MODE, DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_SLEEP, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) +from homeassistant.components.vesync.const import ( + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -222,7 +229,7 @@ async def test_set_mode( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, - {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "auto"}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: MODE_AUTO}, blocking=True, ) await hass.async_block_till_done() @@ -285,3 +292,38 @@ async def test_valid_mist_modes( await hass.async_block_till_done() assert "Unknown mode 'auto'" not in caplog.text assert "Unknown mode 'manual'" not in caplog.text + + +async def test_set_mode_sleep_turns_display_off( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, +) -> None: + """Test update of display for sleep mode.""" + + # First define valid mist modes + humidifier.mist_modes = [ + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + ] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with ( + patch.object(humidifier, "set_humidity_mode", return_value=True), + patch.object(humidifier, "set_display") as display_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: MODE_SLEEP}, + blocking=True, + ) + display_mock.assert_called_once_with(False) From 5a6313858198e815b1e9ea4f0cc7a49f12a86053 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Feb 2025 10:54:32 +0100 Subject: [PATCH 116/178] Fixes in user-facing strings of Tado integration (#137158) --- homeassistant/components/tado/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 735fe34bcf4..f1550517457 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -14,7 +14,7 @@ }, "reconfigure": { "title": "Reconfigure your Tado", - "description": "Reconfigure the entry, for your account: `{username}`.", + "description": "Reconfigure the entry for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, @@ -25,7 +25,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this tado account.", + "no_homes": "There are no homes linked to this Tado account.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } @@ -33,7 +33,7 @@ "options": { "step": { "init": { - "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Dont change until you cancel; TADO_DEFAULT:= Change based on your setting in Tado App).", + "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Don't change until you cancel; TADO_DEFAULT:= Change based on your setting in the Tado app).", "data": { "fallback": "Choose fallback mode." }, @@ -102,11 +102,11 @@ }, "time_period": { "name": "Time period", - "description": "Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay." + "description": "Choose this or 'Overlay'. Set the time period for the change if you want to be specific." }, "requested_overlay": { "name": "Overlay", - "description": "Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting." + "description": "Choose this or 'Time period'. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on Tado app setting." } } }, @@ -151,8 +151,8 @@ }, "issues": { "water_heater_fallback": { - "title": "Tado Water Heater entities now support fallback options", - "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." + "title": "Tado water heater entities now support fallback options", + "description": "Due to added support for water heaters entities, these entities may use a different overlay. Please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." } } } From 523835080b085a71af3314281100bc5c3ee74027 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 3 Feb 2025 08:26:08 +0100 Subject: [PATCH 117/178] Bump pypck to 0.8.5 (#137176) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 2ac183dcc97..c1dd7751940 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"] + "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc0db578cba..26e1e63c226 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2202,7 +2202,7 @@ pypalazzetti==0.1.19 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.3 +pypck==0.8.5 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 92c8538c966..ab737faf9eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.3 +pypck==0.8.5 # homeassistant.components.pjlink pypjlink2==1.2.1 From b318fb46a0615563ef46889d5287c9e55382964f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 3 Feb 2025 00:58:33 -0700 Subject: [PATCH 118/178] Vesync bump pyvesync library (#137208) --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../vesync/snapshots/test_diagnostics.ambr | 33 ++++++++++++++++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index cdb5ed96652..b3697844f19 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.16"] + "requirements": ["pyvesync==2.1.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 26e1e63c226..fa0ae11d9f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2513,7 +2513,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.16 +pyvesync==2.1.17 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab737faf9eb..f7ed3a55709 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2031,7 +2031,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.16 +pyvesync==2.1.17 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 54ed8acf2d7..1c409dbab00 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -128,7 +128,7 @@ 'sleep', 'manual', ]), - 'mode': None, + 'mode': 'humidity', 'night_light': True, 'pid': None, 'speed': None, @@ -160,6 +160,30 @@ # --- # name: test_async_get_device_diagnostics__single_fan dict({ + '_config_dict': dict({ + 'features': list([ + 'air_quality', + ]), + 'levels': list([ + 1, + 2, + ]), + 'models': list([ + 'LV-PUR131S', + 'LV-RH131S', + ]), + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), + 'module': 'VeSyncAir131', + }), + '_features': list([ + 'air_quality', + ]), + 'air_quality_feature': True, 'cid': 'abcdefghabcdefghabcdefghabcdefgh', 'config': dict({ }), @@ -180,6 +204,7 @@ 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', + 'enabled': True, 'extension': None, 'home_assistant': dict({ 'disabled': False, @@ -271,6 +296,12 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), 'pid': None, 'speed': None, 'sub_device_no': None, From e9600532268a758badc6a3dacef5d50334ba912f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Feb 2025 11:51:29 +0100 Subject: [PATCH 119/178] Check for errors when restoring backups using supervisor (#137217) * Check for errors when restoring backups using supervisor * Break long line in test * Improve comments --- homeassistant/components/hassio/backup.py | 25 +++- tests/components/hassio/test_backup.py | 143 ++++++++++++++++++++-- 2 files changed, 157 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 495e953df9d..34d1c62aed7 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -517,17 +517,22 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise HomeAssistantError(message) from err restore_complete = asyncio.Event() + restore_errors: list[dict[str, str]] = [] @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + restore_errors.extend(data.get("errors", [])) unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() + if restore_errors: + # We should add more specific error handling here in the future + raise BackupReaderWriterError(f"Restore failed: {restore_errors}") finally: unsub() @@ -554,11 +559,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return - on_progress( - RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.COMPLETED + restore_errors = data.get("errors", []) + if restore_errors: + _LOGGER.warning("Restore backup failed: %s", restore_errors) + # We should add more specific error handling here in the future + on_progress( + RestoreBackupEvent( + reason="unknown_error", + stage=None, + state=RestoreBackupState.FAILED, + ) + ) + else: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) ) - ) on_progress(IdleEvent()) unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index d001a358640..f35ddeaabbd 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -324,6 +324,24 @@ TEST_JOB_DONE = supervisor_jobs.Job( errors=[], child_jobs=[], ) +TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( + name="backup_manager_partial_restore", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[ + supervisor_jobs.JobError( + type="BackupInvalidError", + message=( + "Backup was made on supervisor version 2025.02.2.dev3105, " + "can't restore on 2025.01.2.dev3105" + ), + ) + ], + child_jobs=[], +) @pytest.fixture(autouse=True) @@ -1946,6 +1964,97 @@ async def test_reader_writer_restore_error( assert response["error"]["code"] == expected_error_code +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_late_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup with error.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + event = { + "event": "job", + "data": { + "name": "backup_manager_partial_restore", + "reference": "7c54aeed", + "uuid": TEST_JOB_ID, + "progress": 0, + "stage": None, + "done": True, + "parent_id": None, + "errors": [ + { + "type": "BackupInvalidError", + "message": ( + "Backup was made on supervisor version 2025.02.2.dev3105, can't" + " restore on 2025.01.2.dev3105. Must update supervisor first." + ), + } + ], + "created": "2025-02-03T08:27:49.297997+00:00", + }, + } + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": "backup_reader_writer_error", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": ( + "Restore failed: [{'type': 'BackupInvalidError', 'message': \"Backup " + "was made on supervisor version 2025.02.2.dev3105, can't restore on " + '2025.01.2.dev3105. Must update supervisor first."}]' + ), + } + + @pytest.mark.parametrize( ("backup", "backup_details", "parameters", "expected_error"), [ @@ -1999,15 +2108,40 @@ async def test_reader_writer_restore_wrong_parameters( } +@pytest.mark.parametrize( + ("get_job_result", "last_non_idle_event"), + [ + ( + TEST_JOB_DONE, + { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + }, + ), + ( + TEST_RESTORE_JOB_DONE_WITH_ERROR, + { + "manager_state": "restore_backup", + "reason": "unknown_error", + "stage": None, + "state": "failed", + }, + ), + ], +) @pytest.mark.usefixtures("hassio_client") async def test_restore_progress_after_restart( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + last_non_idle_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + supervisor_client.jobs.get_job.return_value = get_job_result with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2018,12 +2152,7 @@ async def test_restore_progress_after_restart( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == { - "manager_state": "restore_backup", - "reason": "", - "stage": None, - "state": "completed", - } + assert response["result"]["last_non_idle_event"] == last_non_idle_event assert response["result"]["state"] == "idle" From 88e5d1c18fd8db095261355cc9ed7d6b07739e4e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Feb 2025 17:33:03 +0100 Subject: [PATCH 120/178] Check for errors when creating backups using supervisor (#137220) * Check for errors when creating backups using supervisor * Improve error reporting when there's no backup reference --- homeassistant/components/hassio/backup.py | 9 ++++-- tests/components/hassio/test_backup.py | 37 ++++++++++++++++++----- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 34d1c62aed7..4aad984cc54 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -354,6 +354,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): """Wait for a backup to complete.""" backup_complete = asyncio.Event() backup_id: str | None = None + create_errors: list[dict[str, str]] = [] @callback def on_job_progress(data: Mapping[str, Any]) -> None: @@ -361,6 +362,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): nonlocal backup_id if data.get("done") is True: backup_id = data.get("reference") + create_errors.extend(data.get("errors", [])) backup_complete.set() unsub = self._async_listen_job_events(backup.job_id, on_job_progress) @@ -369,8 +371,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): await backup_complete.wait() finally: unsub() - if not backup_id: - raise BackupReaderWriterError("Backup failed") + if not backup_id or create_errors: + # We should add more specific error handling here in the future + raise BackupReaderWriterError( + f"Backup failed: {create_errors or 'no backup_id'}" + ) async def open_backup() -> AsyncIterator[bytes]: try: diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index f35ddeaabbd..ab3335e00dc 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -1360,11 +1360,40 @@ async def test_reader_writer_create_partial_backup_error( assert supervisor_client.backups.partial_backup.call_count == 1 +@pytest.mark.parametrize( + "supervisor_event", + [ + # Missing backup reference + { + "event": "job", + "data": { + "done": True, + "uuid": TEST_JOB_ID, + }, + }, + # Errors + { + "event": "job", + "data": { + "done": True, + "errors": [ + { + "type": "BackupMountDownError", + "message": "test_mount is down, cannot back-up to it", + } + ], + "uuid": TEST_JOB_ID, + "reference": "test_slug", + }, + }, + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_create_missing_reference_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + supervisor_event: dict[str, Any], ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) @@ -1395,13 +1424,7 @@ async def test_reader_writer_create_missing_reference_error( assert supervisor_client.backups.partial_backup.call_count == 1 await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": TEST_JOB_ID}, - }, - } + {"type": "supervisor/event", "data": supervisor_event} ) response = await client.receive_json() assert response["success"] From bb9740991ecd6037c1cfd2104f241adb61636f53 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:05:11 +0100 Subject: [PATCH 121/178] Fix retrieving PIN when no pin is set on mount in motionmount integration (#137230) --- homeassistant/components/motionmount/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 57a5f638d54..0a774e6efb6 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -28,7 +28,7 @@ class MotionMountEntity(Entity): self.config_entry = config_entry # We store the pin, as we might need it during reconnect - self.pin = config_entry.data[CONF_PIN] + self.pin = config_entry.data.get(CONF_PIN) mac = format_mac(mm.mac.hex()) From 7e32342eb23da4393820cd65555e0bb2ba651ad7 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Mon, 3 Feb 2025 14:05:51 +0100 Subject: [PATCH 122/178] Fix minor issues in Homee (#137239) --- homeassistant/components/homee/const.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index d1d5be97ef7..1d7ce27335f 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -1,6 +1,7 @@ """Constants for the homee integration.""" from homeassistant.const import ( + DEGREE, LIGHT_LUX, PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -32,6 +33,7 @@ HOMEE_UNIT_TO_HA_UNIT = { "W": UnitOfPower.WATT, "m/s": UnitOfSpeed.METERS_PER_SECOND, "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "°": DEGREE, "°F": UnitOfTemperature.FAHRENHEIT, "°C": UnitOfTemperature.CELSIUS, "K": UnitOfTemperature.KELVIN, @@ -51,7 +53,7 @@ OPEN_CLOSE_MAP_REVERSED = { 0.0: "closed", 1.0: "open", 2.0: "partial", - 3.0: "cosing", + 3.0: "closing", 4.0: "opening", } WINDOW_MAP = { From 30b309d7a14b7ea64cca756668db671453df4e4e Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Mon, 3 Feb 2025 18:00:36 +0200 Subject: [PATCH 123/178] Bump python-roborock to 2.11.1 (#137244) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 76d7ab98a34..db2654d4baa 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.9.7", + "python-roborock==2.11.1", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index fa0ae11d9f3..5646075e5ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2449,7 +2449,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.9.7 +python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7ed3a55709..7c9e121260d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.9.7 +python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 From 455af9179be6c69c59921e575ef02ce22fe8197f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 3 Feb 2025 17:30:27 +0100 Subject: [PATCH 124/178] Bump onedrive-personal-sdk to 0.0.2 (#137252) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 767426058c1..263c73a9f69 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.1"] + "requirements": ["onedrive-personal-sdk==0.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5646075e5ad..592b6ebad17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.1 +onedrive-personal-sdk==0.0.2 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c9e121260d..d8e31535fdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.1 +onedrive-personal-sdk==0.0.2 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 1e1069b6475e2a7b676983067cc8547fed12bd5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 3 Feb 2025 19:54:09 +0000 Subject: [PATCH 125/178] Allow ignored idasen_desk devices to be set up from the user flow (#137253) --- .../components/idasen_desk/config_flow.py | 2 +- .../idasen_desk/test_config_flow.py | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index 782d4988a3c..aa832fdfe48 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -87,7 +87,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( discovery.address in current_addresses diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index baeed6be1ab..15baac1b055 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -50,6 +50,49 @@ async def test_user_step_success(hass: HomeAssistant, mock_desk_api: MagicMock) assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_replaces_ignored_device( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test user step replaces ignored devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=IDASEN_DISCOVERY_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( From 044bafd6aa809f1887ee56dcae28d6d892d33b5f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 12:24:30 +0100 Subject: [PATCH 126/178] Improve shutdown of _CipherBackupStreamer (#137257) * Improve shutdown of _CipherBackupStreamer * Catch the right exception --- homeassistant/components/backup/util.py | 56 ++++++++-- tests/components/backup/test_util.py | 139 ++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index fbb13b4721a..b920c66a9b8 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine +from concurrent.futures import CancelledError, Future import copy from dataclasses import dataclass, replace from io import BytesIO @@ -12,6 +13,7 @@ import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile +import threading from typing import IO, Any, Self, cast import aiohttp @@ -22,7 +24,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object -from homeassistant.util.thread import ThreadWithException from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder @@ -167,23 +168,38 @@ class AsyncIteratorReader: def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: """Initialize the wrapper.""" + self._aborted = False self._hass = hass self._stream = stream self._buffer: bytes | None = None + self._next_future: Future[bytes | None] | None = None self._pos: int = 0 async def _next(self) -> bytes | None: """Get the next chunk from the iterator.""" return await anext(self._stream, None) + def abort(self) -> None: + """Abort the reader.""" + self._aborted = True + if self._next_future is not None: + self._next_future.cancel() + def read(self, n: int = -1, /) -> bytes: """Read data from the iterator.""" result = bytearray() while n < 0 or len(result) < n: if not self._buffer: - self._buffer = asyncio.run_coroutine_threadsafe( + self._next_future = asyncio.run_coroutine_threadsafe( self._next(), self._hass.loop - ).result() + ) + if self._aborted: + self._next_future.cancel() + raise AbortCipher + try: + self._buffer = self._next_future.result() + except CancelledError as err: + raise AbortCipher from err self._pos = 0 if not self._buffer: # The stream is exhausted @@ -205,9 +221,11 @@ class AsyncIteratorWriter: def __init__(self, hass: HomeAssistant) -> None: """Initialize the wrapper.""" + self._aborted = False self._hass = hass self._pos: int = 0 self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) + self._write_future: Future[bytes | None] | None = None def __aiter__(self) -> Self: """Return the iterator.""" @@ -219,13 +237,28 @@ class AsyncIteratorWriter: return data raise StopAsyncIteration + def abort(self) -> None: + """Abort the writer.""" + self._aborted = True + if self._write_future is not None: + self._write_future.cancel() + def tell(self) -> int: """Return the current position in the iterator.""" return self._pos def write(self, s: bytes, /) -> int: """Write data to the iterator.""" - asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + self._write_future = asyncio.run_coroutine_threadsafe( + self._queue.put(s), self._hass.loop + ) + if self._aborted: + self._write_future.cancel() + raise AbortCipher + try: + self._write_future.result() + except CancelledError as err: + raise AbortCipher from err self._pos += len(s) return len(s) @@ -415,7 +448,9 @@ def _encrypt_backup( class _CipherWorkerStatus: done: asyncio.Event error: Exception | None = None - thread: ThreadWithException + reader: AsyncIteratorReader + thread: threading.Thread + writer: AsyncIteratorWriter class _CipherBackupStreamer: @@ -468,11 +503,13 @@ class _CipherBackupStreamer: stream = await self._open_stream() reader = AsyncIteratorReader(self._hass, stream) writer = AsyncIteratorWriter(self._hass) - worker = ThreadWithException( + worker = threading.Thread( target=self._cipher_func, args=[reader, writer, self._password, on_done, self.size(), self._nonces], ) - worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker) + worker_status = _CipherWorkerStatus( + done=asyncio.Event(), reader=reader, thread=worker, writer=writer + ) self._workers.append(worker_status) worker.start() return writer @@ -480,9 +517,8 @@ class _CipherBackupStreamer: async def wait(self) -> None: """Wait for the worker threads to finish.""" for worker in self._workers: - if not worker.thread.is_alive(): - continue - worker.thread.raise_exc(AbortCipher) + worker.reader.abort() + worker.writer.abort() await asyncio.gather(*(worker.done.wait() for worker in self._workers)) diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 3bcb53f7c86..3b188ff8226 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator import dataclasses import tarfile @@ -189,6 +190,73 @@ async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: assert decrypted_output == decrypted_backup_data + expected_padding +async def test_decrypted_backup_streamer_interrupt_stuck_reader( + hass: HomeAssistant, +) -> None: + """Test the decrypted backup streamer.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + stuck = asyncio.Event() + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + await stuck.wait() + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_decrypted_backup_streamer_interrupt_stuck_writer( + hass: HomeAssistant, +) -> None: + """Test the decrypted backup streamer.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None: """Test the decrypted backup streamer with wrong password.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) @@ -279,6 +347,77 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: assert encrypted_output == encrypted_backup_data + expected_padding +async def test_encrypted_backup_streamer_interrupt_stuck_reader( + hass: HomeAssistant, +) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + stuck = asyncio.Event() + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + await stuck.wait() + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_encrypted_backup_streamer_interrupt_stuck_writer( + hass: HomeAssistant, +) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None: """Test the encrypted backup streamer.""" decrypted_backup_path = get_fixture_path( From 4bbb3e351b6962cebaf5d934cfa3d612c7be2d6a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:47:50 +0100 Subject: [PATCH 127/178] Remove v2 API support for HomeWizard P1 Meter (#137261) --- .../components/homewizard/__init__.py | 7 +++-- tests/components/homewizard/conftest.py | 2 +- tests/components/homewizard/test_init.py | 31 +++++++++++++++++++ tests/components/homewizard/test_repair.py | 4 +++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 1f29be8e6b6..36c9681dcd2 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -25,7 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - api: HomeWizardEnergy - if token := entry.data.get(CONF_TOKEN): + is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False + + if (token := entry.data.get(CONF_TOKEN)) and is_battery: api = HomeWizardEnergyV2( entry.data[CONF_IP_ADDRESS], token=token, @@ -37,7 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) - await async_check_v2_support_and_create_issue(hass, entry) + if is_battery: + await async_check_v2_support_and_create_issue(hass, entry) coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index f9c5e617904..b8367f87e57 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -160,7 +160,7 @@ def mock_config_entry_v2() -> MockConfigEntry: CONF_IP_ADDRESS: "127.0.0.1", CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", }, - unique_id="HWE-P1_5c2fafabcdef", + unique_id="HWE-BAT_5c2fafabcdef", ) diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 77366da84c5..412ddb13eda 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -52,6 +53,36 @@ async def test_load_unload_v2( assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED +async def test_load_unload_v2_as_v1( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config, but without using it.""" + + # Simulate v2 config but as a P1 Meter + mock_config_entry = MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py index a59d6f415dd..763af48b1a2 100644 --- a/tests/components/homewizard/test_repair.py +++ b/tests/components/homewizard/test_repair.py @@ -36,6 +36,10 @@ async def test_repair_acquires_token( client = await hass_client() mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id="HWE-BAT_5c2fafabcdef" + ) + await hass.async_block_till_done() with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): await hass.config_entries.async_setup(mock_config_entry.entry_id) From 728a1a4be5e8d71d3c7527735f02fb6c09a39792 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 3 Feb 2025 19:37:12 +0100 Subject: [PATCH 128/178] Update frontend to 20250203.0 (#137263) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2ecb165554a..93d5488be03 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250131.0"] + "requirements": ["home-assistant-frontend==20250203.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 949d1885511..a23433f3333 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250131.0 +home-assistant-frontend==20250203.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 592b6ebad17..96e9584b637 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250131.0 +home-assistant-frontend==20250203.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8e31535fdc..d5da51dc683 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250131.0 +home-assistant-frontend==20250203.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 623c82e5d1379bc875d06be23012696c82453bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 4 Feb 2025 08:25:18 +0100 Subject: [PATCH 129/178] Bump pymill to 0.12.3 (#137264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mill lib 0.12.3 Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 6316eb72096..44c1136b7d5 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.12.2", "mill-local==0.3.0"] + "requirements": ["millheater==0.12.3", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 96e9584b637..2962ceaa2f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1405,7 +1405,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.2 +millheater==0.12.3 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5da51dc683..eba4cc84efc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1177,7 +1177,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.2 +millheater==0.12.3 # homeassistant.components.minio minio==7.1.12 From fa8225d0a2be6a8df1cbb392a8f735ee2f396d82 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 4 Feb 2025 17:51:13 +1000 Subject: [PATCH 130/178] Bump tesla-fleet-api to 0.9.2 (#137295) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index aecc6a04af3..fa0f336eb18 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2"] + "requirements": ["tesla-fleet-api==0.9.6"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7a3d0905ea1..09cab85dfe4 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.6.6"] + "requirements": ["tesla-fleet-api==0.9.6", "teslemetry-stream==0.6.6"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 8f7c9890664..f6015b0ef4e 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.2"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2962ceaa2f6..4b04f0c6e55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2854,7 +2854,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.2 +tesla-fleet-api==0.9.6 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eba4cc84efc..5f2bea0985d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2294,7 +2294,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.2 +tesla-fleet-api==0.9.6 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 0766b47161afb59a9eec27b872cb99a26efc0ca9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 4 Feb 2025 11:20:06 +0100 Subject: [PATCH 131/178] Fix data update coordinator garbage collection (#137299) --- homeassistant/helpers/debounce.py | 4 ++++ tests/helpers/test_debounce.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 83555b56dcb..c46c6806d5d 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -146,6 +146,10 @@ class Debouncer[_R_co]: """Cancel any scheduled call, and prevent new runs.""" self._shutdown_requested = True self.async_cancel() + # Release hard references to parent function + # https://github.com/home-assistant/core/issues/137237 + self._function = None + self._job = None @callback def async_cancel(self) -> None: diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 6fa758aec6e..b2dd8943e78 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -4,6 +4,7 @@ import asyncio from datetime import timedelta import logging from unittest.mock import AsyncMock, Mock +import weakref import pytest @@ -529,3 +530,37 @@ async def test_background( async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done(wait_background_tasks=False) assert len(calls) == 2 + + +async def test_shutdown_releases_parent_class(hass: HomeAssistant) -> None: + """Test shutdown releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + """ + calls = [] + + class SomeClass: + def run_func(self) -> None: + calls.append(None) + + my_class = SomeClass() + my_class_weak_ref = weakref.ref(my_class) + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=my_class.run_func, + ) + + # Debouncer keeps a reference to the function, prevening GC + del my_class + await debouncer.async_call() + await hass.async_block_till_done() + assert len(calls) == 1 + assert my_class_weak_ref() is not None + + # Debouncer shutdown releases the class + debouncer.async_shutdown() + assert my_class_weak_ref() is None From 13bfa8203871135bf7f0d06743fdd164d18f1b7a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 11:36:03 +0100 Subject: [PATCH 132/178] Report progress while creating supervisor backup (#137301) * Report progress while creating supervisor backup * Use enum util --- homeassistant/components/backup/__init__.py | 4 + homeassistant/components/hassio/backup.py | 13 +++ tests/components/hassio/test_backup.py | 107 ++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 86e5b95d196..f97805b1923 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -26,6 +26,8 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + CreateBackupStage, + CreateBackupState, IdleEvent, IncorrectPasswordError, ManagerBackup, @@ -49,6 +51,8 @@ __all__ = [ "BackupReaderWriter", "BackupReaderWriterError", "CreateBackupEvent", + "CreateBackupStage", + "CreateBackupState", "Folder", "IdleEvent", "IncorrectPasswordError", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 4aad984cc54..43451e96b37 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -30,6 +30,8 @@ from homeassistant.components.backup import ( BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, + CreateBackupStage, + CreateBackupState, Folder, IdleEvent, IncorrectPasswordError, @@ -47,6 +49,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util +from homeassistant.util.enum import try_parse_enum from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -336,6 +339,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): self._async_wait_for_backup( backup, locations, + on_progress=on_progress, remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", @@ -349,6 +353,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup: supervisor_backups.NewBackup, locations: list[str | None], *, + on_progress: Callable[[CreateBackupEvent], None], remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" @@ -360,6 +365,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup progress.""" nonlocal backup_id + if not (stage := try_parse_enum(CreateBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown create stage: %s", data.get("stage")) + else: + on_progress( + CreateBackupEvent( + reason=None, stage=stage, state=CreateBackupState.IN_PROGRESS + ) + ) if data.get("done") is True: backup_id = data.get("reference") create_errors.extend(data.get("errors", [])) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index ab3335e00dc..023a19a223f 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -1002,6 +1002,113 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": False, "stage": "addons"}, + supervisor_event_base | {"done": True, "stage": "finishing_file"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + "finishing_file", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_create_job_done( hass: HomeAssistant, From fa9b4c352486f225f6aae85cbd8b5db07c2696e9 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 4 Feb 2025 12:39:00 +0100 Subject: [PATCH 133/178] Bump onedrive-personal-sdk to 0.0.3 (#137309) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 263c73a9f69..cd44298384a 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.2"] + "requirements": ["onedrive-personal-sdk==0.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4b04f0c6e55..7af5aedcf05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.2 +onedrive-personal-sdk==0.0.3 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f2bea0985d..eef65c0d655 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.2 +onedrive-personal-sdk==0.0.3 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 834a04ac4947b8ea41b71ca8f2789a9af144c251 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Feb 2025 12:26:19 +0000 Subject: [PATCH 134/178] Bump version to 2025.2.0b8 --- 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 96613bf164a..542b034bd79 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 = "0b7" +PATCH_VERSION: Final = "0b8" __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 1bbec0e596f..2eb93d28b27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b7" +version = "2025.2.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 55c746f9092c9db364a6879e9cee3a3cea698c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 4 Feb 2025 16:52:40 +0000 Subject: [PATCH 135/178] Add view to download support package to Cloud component (#135856) --- homeassistant/components/cloud/http_api.py | 55 ++++++++++ .../components/system_health/__init__.py | 65 ++++++++--- .../cloud/snapshots/test_http_api.ambr | 49 +++++++++ tests/components/cloud/test_http_api.py | 102 +++++++++++++++++- 4 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 tests/components/cloud/snapshots/test_http_api.ambr diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 473f553593a..b1a845ef8b0 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.system_health import get_info as get_system_health_info from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(DownloadSupportPackageView) _CLOUD_ERRORS.update( { @@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView): return self.json_message("ok") +class DownloadSupportPackageView(HomeAssistantView): + """Download support package view.""" + + url = "/api/cloud/support_package" + name = "api:cloud:support_package" + + def _generate_markdown( + self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] + ) -> str: + def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: + if len(domain_info) == 0: + return "No information available\n" + + markdown = "" + first = True + for key, value in domain_info.items(): + markdown += f"{key} | {value}\n" + if first: + markdown += "--- | ---\n" + first = False + return markdown + "\n" + + markdown = "## System Information\n\n" + markdown += get_domain_table_markdown(hass_info) + + for domain, domain_info in domains_info.items(): + domain_info_md = get_domain_table_markdown(domain_info) + markdown += ( + f"
{domain}\n\n" + f"{domain_info_md}" + "
\n\n" + ) + + return markdown + + async def get(self, request: web.Request) -> web.Response: + """Download support package file.""" + + hass = request.app[KEY_HASS] + domain_health = await get_system_health_info(hass) + + hass_info = domain_health.pop("homeassistant", {}) + markdown = self._generate_markdown(hass_info, domain_health) + + return web.Response( + body=markdown, + content_type="text/markdown", + headers={ + "Content-Disposition": 'attachment; filename="support_package.md"' + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"}) @websocket_api.async_response diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index ce80f6303d9..7d2224fc6fc 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable import dataclasses from datetime import datetime import logging @@ -101,6 +101,57 @@ async def get_integration_info( return result +async def _registered_domain_data( + hass: HomeAssistant, +) -> AsyncGenerator[tuple[str, dict[str, Any]]]: + registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] + for domain, domain_data in zip( + registrations, + await asyncio.gather( + *( + get_integration_info(hass, registration) + for registration in registrations.values() + ) + ), + strict=False, + ): + yield domain, domain_data + + +async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]: + """Get the full set of system health information.""" + domains: dict[str, dict[str, Any]] = {} + + async def _get_info_value(value: Any) -> Any: + if not asyncio.iscoroutine(value): + return value + try: + return await value + except Exception as exception: + _LOGGER.exception("Error fetching system info for %s - %s", domain, key) + return f"Exception: {exception}" + + async for domain, domain_data in _registered_domain_data(hass): + domain_info: dict[str, Any] = {} + for key, value in domain_data["info"].items(): + info_value = await _get_info_value(value) + + if isinstance(info_value, datetime): + domain_info[key] = info_value.isoformat() + elif ( + isinstance(info_value, dict) + and "type" in info_value + and info_value["type"] == "failed" + ): + domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}" + else: + domain_info[key] = info_value + + domains[domain] = domain_info + + return domains + + @callback def _format_value(val: Any) -> Any: """Format a system health value.""" @@ -115,20 +166,10 @@ async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle an info request via a subscription.""" - registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] data = {} pending_info: dict[tuple[str, str], asyncio.Task] = {} - for domain, domain_data in zip( - registrations, - await asyncio.gather( - *( - get_integration_info(hass, registration) - for registration in registrations.values() - ) - ), - strict=False, - ): + async for domain, domain_data in _registered_domain_data(hass): for key, value in domain_data["info"].items(): if asyncio.iscoroutine(value): value = asyncio.create_task(value) diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr new file mode 100644 index 00000000000..9b2f2e0eb33 --- /dev/null +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_download_support_package + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + +
mock_no_info_integration + + No information available +
+ +
cloud + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | CertificateStatus.READY + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + +
+ + + ''' +# --- diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 910fa03d46c..e4a526ceadd 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,10 +1,11 @@ """Tests for the HTTP API for the cloud component.""" +from collections.abc import Callable, Coroutine from copy import deepcopy from http import HTTPStatus import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from hass_nabucasa import thingtalk @@ -15,9 +16,12 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.remote import CertificateStatus from hass_nabucasa.voice import TTS_VOICES import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import system_health from homeassistant.components.alexa import errors as alexa_errors # pylint: disable-next=hass-component-root-import @@ -30,8 +34,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo +from tests.common import mock_platform from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -113,6 +119,7 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "user_pool_id": "user_pool_id", "region": "region", "relayer_server": "relayer", + "acme_server": "cert-server", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, "alexa": { @@ -1860,3 +1867,96 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.call_count == 1 assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} + + +async def test_download_support_package( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, +) -> None: + """Test downloading a support package file.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot From 09e02493b7f9417ddba893f9f2294b25ed76c023 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:20:54 +0100 Subject: [PATCH 136/178] Improve backup file naming in Synology DSM backup agent (#137278) * improve backup file naming * use built-in suggested_filename --- .../components/synology_dsm/backup.py | 49 +++++++++++++++++-- tests/components/synology_dsm/test_backup.py | 46 +++++++++-------- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 5f3312717ef..83c3455bdf1 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -10,7 +10,12 @@ from aiohttp import StreamReader from synology_dsm.api.file_station import SynoFileStation from synology_dsm.exceptions import SynologyDSMAPIErrorException -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + suggested_filename, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -28,6 +33,15 @@ from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Suggest filenames for the backup. + + returns a tuple of tar_filename and meta_filename + """ + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return (f"{base_name}.tar", f"{base_name}_meta.json") + + async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: @@ -95,6 +109,19 @@ class SynologyDSMBackupAgent(BackupAgent): assert self.api.file_station return self.api.file_station + async def _async_suggested_filenames( + self, + backup_id: str, + ) -> tuple[str, str]: + """Suggest filenames for the backup. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: A tuple of tar_filename and meta_filename + """ + if (backup := await self.async_get_backup(backup_id)) is None: + raise BackupAgentError("Backup not found") + return suggested_filenames(backup) + async def async_download_backup( self, backup_id: str, @@ -105,10 +132,12 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ + (filename_tar, _) = await self._async_suggested_filenames(backup_id) + try: resp = await self._file_station.download_file( path=self.path, - filename=f"{backup_id}.tar", + filename=filename_tar, ) except SynologyDSMAPIErrorException as err: raise BackupAgentError("Failed to download backup") from err @@ -131,11 +160,13 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup: Metadata about the backup that should be uploaded. """ + (filename_tar, filename_meta) = suggested_filenames(backup) + # upload backup.tar file first try: await self._file_station.upload_file( path=self.path, - filename=f"{backup.backup_id}.tar", + filename=filename_tar, source=await open_stream(), create_parents=True, ) @@ -146,7 +177,7 @@ class SynologyDSMBackupAgent(BackupAgent): try: await self._file_station.upload_file( path=self.path, - filename=f"{backup.backup_id}_meta.json", + filename=filename_meta, source=json_dumps(backup.as_dict()).encode(), ) except SynologyDSMAPIErrorException as err: @@ -161,7 +192,15 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - for filename in (f"{backup_id}.tar", f"{backup_id}_meta.json"): + try: + (filename_tar, filename_meta) = await self._async_suggested_filenames( + backup_id + ) + except BackupAgentError: + # backup meta data could not be found, so we can't delete the backup + return + + for filename in (filename_tar, filename_meta): try: await self._file_station.delete_file(path=self.path, filename=filename) except SynologyDSMAPIErrorException as err: diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index bcd9f1aa4eb..764ad8d4c01 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -36,6 +36,8 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" + class MockStreamReaderChunked(MockStreamReader): """Mock a stream reader with simulated chunked data.""" @@ -46,14 +48,14 @@ class MockStreamReaderChunked(MockStreamReader): async def _mock_download_file(path: str, filename: str) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' ) - if filename == "abcd12ef.tar": + if filename == f"{BASE_FILENAME}.tar": return MockStreamReaderChunked(b"backup data") raise MockStreamReaderChunked(b"") @@ -61,22 +63,22 @@ async def _mock_download_file(path: str, filename: str) -> MockStreamReader: async def _mock_download_file_meta_ok_tar_missing( path: str, filename: str ) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader( b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' ) - if filename == "abcd12ef.tar": - raise SynologyDSMAPIErrorException("api", "404", "not found") + if filename == f"{BASE_FILENAME}.tar": + raise SynologyDSMAPIErrorException("api", "900", [{"code": 408}]) raise MockStreamReaderChunked(b"") async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader: - if filename == "abcd12ef_meta.json": + if filename == f"{BASE_FILENAME}_meta.json": return MockStreamReader(b"im not a json") - if filename == "abcd12ef.tar": + if filename == f"{BASE_FILENAME}.tar": return MockStreamReaderChunked(b"backup data") raise MockStreamReaderChunked(b"") @@ -84,7 +86,6 @@ async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStrea @pytest.fixture def mock_dsm_with_filestation(): """Mock a successful service with filestation support.""" - with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) @@ -115,14 +116,14 @@ def mock_dsm_with_filestation(): SynoFileFile( additional=None, is_dir=False, - name="abcd12ef_meta.json", - path="/ha_backup/my_backup_path/abcd12ef_meta.json", + name=f"{BASE_FILENAME}_meta.json", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}_meta.json", ), SynoFileFile( additional=None, is_dir=False, - name="abcd12ef.tar", - path="/ha_backup/my_backup_path/abcd12ef.tar", + name=f"{BASE_FILENAME}.tar", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}.tar", ), ] ), @@ -522,6 +523,7 @@ async def test_agents_upload( protected=True, size=0, ) + base_filename = "Test_-_1970-01-01_00.00_00000000" with ( patch( @@ -544,9 +546,9 @@ async def test_agents_upload( assert f"Uploading backup {backup_id}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 2 - assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[1].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}_meta.json" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" @@ -572,6 +574,7 @@ async def test_agents_upload_error( protected=True, size=0, ) + base_filename = "Test_-_1970-01-01_00.00_00000000" # fail to upload the tar file with ( @@ -599,7 +602,7 @@ async def test_agents_upload_error( assert "Failed to upload backup" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 1 - assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" # fail to upload the meta json file @@ -630,9 +633,9 @@ async def test_agents_upload_error( assert "Failed to upload backup" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.upload_file assert len(mock.mock_calls) == 3 - assert mock.call_args_list[1].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}.tar" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[2].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[2].kwargs["filename"] == f"{base_filename}_meta.json" assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path" @@ -657,9 +660,9 @@ async def test_agents_delete( assert response["result"] == {"agent_errors": {}} mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 2 - assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" - assert mock.call_args_list[1].kwargs["filename"] == "abcd12ef_meta.json" + assert mock.call_args_list[1].kwargs["filename"] == f"{BASE_FILENAME}_meta.json" assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" @@ -672,6 +675,9 @@ async def test_agents_delete_not_existing( client = await hass_ws_client(hass) backup_id = "ef34ab12" + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) setup_dsm_with_filestation.file.delete_file = AsyncMock( side_effect=SynologyDSMAPIErrorException( "api", @@ -740,5 +746,5 @@ async def test_agents_delete_error( assert f"Failed to delete backup: {expected_log}" in caplog.text mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 - assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" From d478f906df23f14ce4e7cdc8c56fc18539efc2ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 15:57:30 +0100 Subject: [PATCH 137/178] Include extra metadata in backup WS API (#137296) * Include extra metadata in backup WS API * Update onboarding backup view * Update google_drive tests --- homeassistant/components/backup/models.py | 6 -- homeassistant/components/backup/websocket.py | 4 +- homeassistant/components/onboarding/views.py | 2 +- tests/components/backup/common.py | 4 +- tests/components/backup/conftest.py | 10 +++ .../backup/snapshots/test_backup.ambr | 8 ++ .../backup/snapshots/test_websocket.ambr | 80 +++++++++++++++++++ tests/components/backup/test_manager.py | 31 ++++++- tests/components/cloud/test_backup.py | 2 + tests/components/google_drive/test_backup.py | 1 + tests/components/hassio/test_backup.py | 3 + tests/components/kitchen_sink/test_backup.py | 4 +- .../onboarding/snapshots/test_views.ambr | 8 ++ tests/components/onedrive/test_backup.py | 2 + tests/components/synology_dsm/test_backup.py | 4 +- 15 files changed, 154 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 1543d577964..62118b7944f 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -41,12 +41,6 @@ class BaseBackup: homeassistant_version: str | None # None if homeassistant_included is False name: str - def as_frontend_json(self) -> dict: - """Return a dict representation of this backup for sending to frontend.""" - return { - key: val for key, val in asdict(self).items() if key != "extra_metadata" - } - @dataclass(frozen=True, kw_only=True) class AgentBackup(BaseBackup): diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 93dd81c3c14..e130b9e950f 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -57,7 +57,7 @@ async def handle_info( "agent_errors": { agent_id: str(err) for agent_id, err in agent_errors.items() }, - "backups": [backup.as_frontend_json() for backup in backups.values()], + "backups": list(backups.values()), "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, "last_non_idle_event": manager.last_non_idle_event, @@ -91,7 +91,7 @@ async def handle_details( "agent_errors": { agent_id: str(err) for agent_id, err in agent_errors.items() }, - "backup": backup.as_frontend_json() if backup else None, + "backup": backup, }, ) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index edf0b615779..1e29860e3c5 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -378,7 +378,7 @@ class BackupInfoView(BackupOnboardingView): backups, _ = await manager.async_get_backups() return self.json( { - "backups": [backup.as_frontend_json() for backup in backups.values()], + "backups": list(backups.values()), "state": manager.state, "last_non_idle_event": manager.last_non_idle_event, } diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index a7888dbd08c..1e7278134d4 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.backup import ( DOMAIN, @@ -29,7 +29,7 @@ TEST_BACKUP_ABC123 = AgentBackup( backup_id="abc123", database_included=True, date="1970-01-01T00:00:00.000Z", - extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, + extra_metadata={"instance_id": "our_uuid", "with_automatic_settings": True}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index d0d9ac7e0e1..eb38399eb79 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -18,6 +18,16 @@ from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path +@pytest.fixture(name="instance_id", autouse=True) +def instance_id_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock instance ID.""" + with patch( + "homeassistant.components.backup.manager.instance_id.async_get", + return_value="our_uuid", + ): + yield + + @pytest.fixture(name="mocked_json_bytes") def mocked_json_bytes_fixture() -> Generator[Mock]: """Mock json_bytes.""" diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 68b00632a6b..28ee9b834c1 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -71,6 +71,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -94,6 +98,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 08c19906241..d5d15e98da6 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3040,6 +3040,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3117,6 +3121,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3175,6 +3183,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3217,6 +3229,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3270,6 +3286,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3321,6 +3341,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3379,6 +3403,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3438,6 +3466,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3497,6 +3527,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ 'test.remote', ]), @@ -3556,6 +3588,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3614,6 +3648,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3672,6 +3708,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3730,6 +3768,8 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', + 'extra_metadata': dict({ + }), 'failed_agent_ids': list([ 'test.remote', ]), @@ -3789,6 +3829,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3828,6 +3872,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3883,6 +3931,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -3923,6 +3975,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4199,6 +4255,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4246,6 +4306,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4297,6 +4361,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4339,6 +4407,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4367,6 +4439,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -4415,6 +4491,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b98cec47e8d..57f11ed4708 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -136,7 +136,7 @@ async def test_create_backup_service( agent_ids=["backup.local"], backup_name="Custom backup 2025.1.0", extra_metadata={ - "instance_id": hass.data["core.uuid"], + "instance_id": "our_uuid", "with_automatic_settings": False, }, include_addons=None, @@ -595,7 +595,7 @@ async def test_initiate_backup( "compressed": True, "date": ANY, "extra": { - "instance_id": hass.data["core.uuid"], + "instance_id": "our_uuid", "with_automatic_settings": False, }, "homeassistant": { @@ -625,6 +625,7 @@ async def test_initiate_backup( "backup_id": backup_id, "database_included": include_database, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": [], "folders": [], "homeassistant_included": True, @@ -675,6 +676,10 @@ async def test_initiate_backup_with_agent_error( "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -691,6 +696,10 @@ async def test_initiate_backup_with_agent_error( "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -713,6 +722,10 @@ async def test_initiate_backup_with_agent_error( "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -836,6 +849,7 @@ async def test_initiate_backup_with_agent_error( "backup_id": "abc123", "database_included": True, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": ["test.remote"], "folders": [], "homeassistant_included": True, @@ -1770,6 +1784,10 @@ async def test_receive_backup_agent_error( "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -1786,6 +1804,10 @@ async def test_receive_backup_agent_error( "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -1808,6 +1830,10 @@ async def test_receive_backup_agent_error( "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -3325,6 +3351,7 @@ async def test_initiate_backup_per_agent_encryption( "backup_id": backup_id, "database_included": True, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": [], "folders": [], "homeassistant_included": True, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c2513168ab9..5b2b8751311 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -174,6 +174,7 @@ async def test_agents_list_backups( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", @@ -223,6 +224,7 @@ async def test_agents_list_backups_fail_cloud( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 7e455ebb535..115a30a3eb6 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -47,6 +47,7 @@ TEST_AGENT_BACKUP_RESULT = { "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", diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 023a19a223f..89396202aa7 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -509,6 +509,7 @@ async def test_agent_info( "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": True, @@ -528,6 +529,7 @@ async def test_agent_info( "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": False, @@ -680,6 +682,7 @@ async def test_agent_get_backup( "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": True, diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index a664b91393d..7c693abcda8 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator from io import StringIO -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest @@ -106,6 +106,7 @@ async def test_agents_list_backups( "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00Z", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["media", "share"], "homeassistant_included": True, @@ -187,6 +188,7 @@ async def test_agents_upload( "backup_id": "test-backup", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": False}, "failed_agent_ids": [], "folders": ["media", "share"], "homeassistant_included": True, diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index b57c6cf96dd..2d084bd9ade 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -19,6 +19,10 @@ 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'abc123', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -42,6 +46,10 @@ 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 3f8c29efa7e..0277c3da02e 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -88,6 +88,7 @@ async def test_agents_list_backups( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", @@ -123,6 +124,7 @@ async def test_agents_get_backup( "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 764ad8d4c01..26e09d407ff 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -2,7 +2,7 @@ from io import StringIO from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch import pytest from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder @@ -300,6 +300,7 @@ async def test_agents_list_backups( "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", @@ -370,6 +371,7 @@ async def test_agents_list_backups_disabled_filestation( "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", From 48c88d8fa12de5b1e4ceab746be4b0968bb33009 Mon Sep 17 00:00:00 2001 From: Matthias Lohr Date: Tue, 4 Feb 2025 20:37:59 +0100 Subject: [PATCH 138/178] Bump tololib to 1.2.2 (#137303) --- homeassistant/components/tolo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json index 14125a857f6..613fc810683 100644 --- a/homeassistant/components/tolo/manifest.json +++ b/homeassistant/components/tolo/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/tolo", "iot_class": "local_polling", "loggers": ["tololib"], - "requirements": ["tololib==1.1.0"] + "requirements": ["tololib==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7af5aedcf05..a372c249b04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2896,7 +2896,7 @@ tmb==0.0.4 todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eef65c0d655..433aa5b3f4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2324,7 +2324,7 @@ tilt-ble==0.2.3 todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 From 8de64b8b1f9aa9bdf1573359e3ff20996ac11245 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Tue, 4 Feb 2025 12:12:49 -0800 Subject: [PATCH 139/178] Allow ignored screenlogic devices to be set up from the user flow (#137315) Allow ignored ScreenLogic devices to be set up from the user flow --- .../components/screenlogic/config_flow.py | 2 +- .../screenlogic/test_config_flow.py | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 0fdf5d96445..b4deb9b36aa 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -105,7 +105,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_gateway_select(self, user_input=None) -> ConfigFlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" - existing = self._async_current_ids() + existing = self._async_current_ids(include_ignore=False) unconfigured_gateways = { mac: gateway[SL_GATEWAY_NAME] for mac, gateway in self.discovered_gateways.items() diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 5ce777a47fa..ad8ef125dac 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -86,6 +86,53 @@ async def test_flow_discover_none(hass: HomeAssistant) -> None: assert result["step_id"] == "gateway_entry" +async def test_flow_replace_ignored(hass: HomeAssistant) -> None: + """Test we can replace ignored entries.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:c0:33:01:01:01", + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.screenlogic.config_flow.discovery.async_discover", + return_value=[ + { + SL_GATEWAY_IP: "1.1.1.1", + SL_GATEWAY_PORT: 80, + SL_GATEWAY_TYPE: 12, + SL_GATEWAY_SUBTYPE: 2, + SL_GATEWAY_NAME: "Pentair: 01-01-01", + }, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "gateway_select" + + with patch( + "homeassistant.components.screenlogic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={GATEWAY_SELECT_KEY: "00:c0:33:01:01:01"} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pentair: 01-01-01" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_flow_discover_error(hass: HomeAssistant) -> None: """Test when discovery errors.""" From 1f967f7f777b7b7591bde0a3aaca91c3f0938a40 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Feb 2025 21:35:04 +0100 Subject: [PATCH 140/178] Minor adjustments of hassio backup tests (#137324) --- tests/components/hassio/test_backup.py | 82 +++++++++++++------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 89396202aa7..4bfedd60108 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -106,7 +106,7 @@ TEST_BACKUP_2 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -136,7 +136,7 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( compressed=TEST_BACKUP_2.compressed, date=TEST_BACKUP_2.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_2.location, @@ -156,7 +156,7 @@ TEST_BACKUP_3 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -186,7 +186,7 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( compressed=TEST_BACKUP_3.compressed, date=TEST_BACKUP_3.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_3.location, @@ -207,7 +207,7 @@ TEST_BACKUP_4 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), @@ -234,23 +234,23 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( version="9.14.0", ) ], - compressed=TEST_BACKUP.compressed, - date=TEST_BACKUP.date, + compressed=TEST_BACKUP_4.compressed, + date=TEST_BACKUP_4.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP.location, - location_attributes=TEST_BACKUP.location_attributes, - locations=TEST_BACKUP.locations, - name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, + location=TEST_BACKUP_4.location, + location_attributes=TEST_BACKUP_4.location_attributes, + locations=TEST_BACKUP_4.locations, + name=TEST_BACKUP_4.name, + protected=TEST_BACKUP_4.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, - slug=TEST_BACKUP.slug, + size=TEST_BACKUP_4.size, + size_bytes=TEST_BACKUP_4.size_bytes, + slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", - type=TEST_BACKUP.type, + type=TEST_BACKUP_4.type, ) TEST_BACKUP_5 = supervisor_backups.Backup( @@ -364,7 +364,7 @@ async def hassio_enabled( @pytest.fixture -async def setup_integration( +async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" @@ -494,7 +494,7 @@ async def test_agent_info( } -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("backup", "backup_details", "expected_response"), [ @@ -560,7 +560,7 @@ async def test_agent_list_backups( assert response["result"]["backups"] == [expected_response] -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -584,7 +584,7 @@ async def test_agent_download( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download_unavailable_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -603,7 +603,7 @@ async def test_agent_download_unavailable_backup( assert resp.status == 404 -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -652,7 +652,7 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -694,7 +694,7 @@ async def test_agent_get_backup( supervisor_client.backups.backup_info.assert_called_once_with(backup_id) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("backup_info_side_effect", "expected_response"), [ @@ -738,7 +738,7 @@ async def test_agent_get_backup_with_error( supervisor_client.backups.backup_info.assert_called_once_with(backup_id) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_delete_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -763,7 +763,7 @@ async def test_agent_delete_backup( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("remove_side_effect", "expected_response"), [ @@ -809,7 +809,7 @@ async def test_agent_delete_with_error( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("event_data", "mount_info_calls"), [ @@ -890,7 +890,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("extra_generate_options", "expected_supervisor_options"), [ @@ -1005,7 +1005,7 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_report_progress( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1112,7 +1112,7 @@ async def test_reader_writer_create_report_progress( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1403,7 +1403,7 @@ async def test_reader_writer_create_per_agent_encryption( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), [ @@ -1498,7 +1498,7 @@ async def test_reader_writer_create_partial_backup_error( }, ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_missing_reference_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1557,7 +1557,7 @@ async def test_reader_writer_create_missing_reference_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) @pytest.mark.parametrize( ("method", "download_call_count", "remove_call_count"), @@ -1651,7 +1651,7 @@ async def test_reader_writer_create_download_remove_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) async def test_reader_writer_create_info_error( hass: HomeAssistant, @@ -1728,7 +1728,7 @@ async def test_reader_writer_create_info_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1812,7 +1812,7 @@ async def test_reader_writer_create_remote_backup( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("extra_generate_options", "expected_error"), [ @@ -1882,7 +1882,7 @@ async def test_reader_writer_create_wrong_parameters( supervisor_client.backups.partial_backup.assert_not_called() -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_receive_remote_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -1958,7 +1958,7 @@ async def test_agent_receive_remote_backup( ), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2036,7 +2036,7 @@ async def test_reader_writer_restore( ), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2097,7 +2097,7 @@ async def test_reader_writer_restore_error( assert response["error"]["code"] == expected_error_code -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_late_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2211,7 +2211,7 @@ async def test_reader_writer_restore_late_error( ), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_wrong_parameters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 99219a9a734301fbf4e8cbfc127e684ced0edd34 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 4 Feb 2025 18:11:55 +0100 Subject: [PATCH 141/178] Bump onedrive-personal-sdk to 0.0.4 (#137330) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index cd44298384a..47eb48742be 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.3"] + "requirements": ["onedrive-personal-sdk==0.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index a372c249b04..e240c5ffa31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.3 +onedrive-personal-sdk==0.0.4 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 433aa5b3f4c..60891372a04 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.3 +onedrive-personal-sdk==0.0.4 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 2005e14d5f989eae23e2ff4e4c624b473b2aca49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 19:49:55 +0100 Subject: [PATCH 142/178] Improve error handling when supervisor backups are deleted (#137331) * Improve error handling when supervisor backups are deleted * Move exception definitions --- homeassistant/components/backup/__init__.py | 3 +- homeassistant/components/backup/agent.py | 14 +---- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/http.py | 16 +++--- homeassistant/components/backup/manager.py | 15 +++--- homeassistant/components/backup/models.py | 18 +++++++ homeassistant/components/backup/websocket.py | 6 ++- homeassistant/components/hassio/backup.py | 16 ++++-- .../backup/snapshots/test_websocket.ambr | 22 ++++++++ tests/components/backup/test_http.py | 52 ++++++++++++++++++- tests/components/backup/test_websocket.py | 37 +++++++++++++ tests/components/hassio/test_backup.py | 41 ++++++++++----- 12 files changed, 195 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index f97805b1923..3ee6d2026f9 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -36,7 +36,7 @@ from .manager import ( RestoreBackupState, WrittenBackup, ) -from .models import AddonInfo, AgentBackup, Folder +from .models import AddonInfo, AgentBackup, BackupNotFound, Folder from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers @@ -47,6 +47,7 @@ __all__ = [ "BackupAgentError", "BackupAgentPlatformProtocol", "BackupManagerError", + "BackupNotFound", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 297ccd6f685..9530f386c7b 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -11,13 +11,7 @@ from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback -from .models import AgentBackup, BackupError - - -class BackupAgentError(BackupError): - """Base class for backup agent errors.""" - - error_code = "backup_agent_error" +from .models import AgentBackup, BackupAgentError class BackupAgentUnreachableError(BackupAgentError): @@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." -class BackupNotFound(BackupAgentError): - """Raised when a backup is not found.""" - - error_code = "backup_not_found" - - class BackupAgent(abc.ABC): """Backup agent interface.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index b6282186c06..c3a46a6ab1f 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,9 +11,9 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, BackupNotFound, LocalBackupAgent +from .agent import BackupAgent, LocalBackupAgent from .const import DOMAIN, LOGGER -from .models import AgentBackup +from .models import AgentBackup, BackupNotFound from .util import read_backup, suggested_filename diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 6b06db4601d..58f44d4a449 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -21,6 +21,7 @@ from . import util from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager +from .models import BackupNotFound @callback @@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } - if not password or not backup.protected: - return await self._send_backup_no_password( - request, headers, backup_id, agent_id, agent, manager + try: + if not password or not backup.protected: + return await self._send_backup_no_password( + request, headers, backup_id, agent_id, agent, manager + ) + return await self._send_backup_with_password( + hass, request, headers, backup_id, agent_id, password, agent, manager ) - return await self._send_backup_with_password( - hass, request, headers, backup_id, agent_id, password, agent, manager - ) + except BackupNotFound: + return Response(status=HTTPStatus.NOT_FOUND) async def _send_backup_no_password( self, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 42b5f522ecd..fa9ca956c22 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -50,7 +50,14 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder +from .models import ( + AgentBackup, + BackupError, + BackupManagerError, + BackupReaderWriterError, + BaseBackup, + Folder, +) from .store import BackupStore from .util import ( AsyncIteratorReader, @@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC): """Get restore events after core restart.""" -class BackupReaderWriterError(BackupError): - """Backup reader/writer error.""" - - error_code = "backup_reader_writer_error" - - class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 62118b7944f..95c5ef9809d 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -77,7 +77,25 @@ class BackupError(HomeAssistantError): error_code = "unknown" +class BackupAgentError(BackupError): + """Base class for backup agent errors.""" + + error_code = "backup_agent_error" + + class BackupManagerError(BackupError): """Backup manager error.""" error_code = "backup_manager_error" + + +class BackupReaderWriterError(BackupError): + """Backup reader/writer error.""" + + error_code = "backup_reader_writer_error" + + +class BackupNotFound(BackupAgentError, BackupManagerError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index e130b9e950f..b6d092e1913 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -15,7 +15,7 @@ from .manager import ( IncorrectPasswordError, ManagerStateEvent, ) -from .models import Folder +from .models import BackupNotFound, Folder @callback @@ -151,6 +151,8 @@ async def handle_restore( restore_folders=msg.get("restore_folders"), restore_homeassistant=msg["restore_homeassistant"], ) + except BackupNotFound: + connection.send_error(msg["id"], "backup_not_found", "Backup not found") except IncorrectPasswordError: connection.send_error(msg["id"], "password_incorrect", "Incorrect password") else: @@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download( agent_id=msg["agent_id"], password=msg.get("password"), ) + except BackupNotFound: + connection.send_error(msg["id"], "backup_not_found", "Backup not found") except IncorrectPasswordError: connection.send_error(msg["id"], "password_incorrect", "Incorrect password") except DecryptOnDowloadNotSupported: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 43451e96b37..dfc161bd4e7 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -27,6 +27,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupManagerError, + BackupNotFound, BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, @@ -161,10 +162,15 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AsyncIterator[bytes]: """Download a backup file.""" - return await self._client.backups.download_backup( - backup_id, - options=supervisor_backups.DownloadBackupOptions(location=self.location), - ) + try: + return await self._client.backups.download_backup( + backup_id, + options=supervisor_backups.DownloadBackupOptions( + location=self.location + ), + ) + except SupervisorNotFoundError as err: + raise BackupNotFound from err async def async_upload_backup( self, @@ -527,6 +533,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): location=restore_location, ), ) + except SupervisorNotFoundError as err: + raise BackupNotFound from err except SupervisorBadRequestError as err: # Supervisor currently does not transmit machine parsable error types message = err.args[0] diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index d5d15e98da6..421432fb66e 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,28 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Unknown error', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound] + dict({ + 'error': dict({ + 'code': 'backup_not_found', + 'message': 'Backup not found', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_config_info[storage_data0] dict({ 'id': 1, diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index aac39c04d31..24fd15fc4fe 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -11,7 +11,13 @@ from unittest.mock import patch from aiohttp import web import pytest -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgentError, + BackupNotFound, + Folder, +) from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant @@ -141,6 +147,50 @@ async def test_downloading_remote_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "domain.test") +@pytest.mark.parametrize( + ("error", "status"), + [ + (BackupAgentError, 500), + (BackupNotFound, 404), + ], +) +@patch.object(BackupAgentTest, "async_download_backup") +async def test_downloading_remote_encrypted_backup_with_error( + download_mock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + error: Exception, + status: int, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + "test", + [ + AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, + ) + ], + ) + + download_mock.side_effect = error + client = await hass_client() + resp = await client.get( + "/api/backup/download/abc123?agent_id=domain.test&password=blah" + ) + assert resp.status == status + + async def _test_downloading_encrypted_backup( hass_client: ClientSessionGenerator, agent_id: str, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 613c0b69b6b..5af6d595938 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -12,6 +12,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgentError, BackupAgentPlatformProtocol, + BackupNotFound, BackupReaderWriterError, Folder, store, @@ -2967,3 +2968,39 @@ async def test_can_decrypt_on_download( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "error", + [ + BackupAgentError, + BackupNotFound, + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_with_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + error: Exception, +) -> None: + """Test can decrypt on download.""" + + await setup_backup_integration( + hass, + with_hassio=False, + backups={"test.remote": [TEST_BACKUP_ABC123]}, + remote_agents=["remote"], + ) + client = await hass_ws_client(hass) + + with patch.object(BackupAgentTest, "async_download_backup", side_effect=error): + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 4bfedd60108..459ebe581fb 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -584,22 +584,29 @@ async def test_agent_download( ) +@pytest.mark.parametrize( + ("backup_info", "backup_id", "agent_id"), + [ + (TEST_BACKUP_DETAILS_3, "unknown", "hassio.local"), + (TEST_BACKUP_DETAILS_3, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + (TEST_BACKUP_DETAILS, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download_unavailable_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, + agent_id: str, + backup_id: str, + backup_info: supervisor_backups.BackupComplete, ) -> None: """Test agent download backup which does not exist.""" client = await hass_client() - backup_id = "abc123" - supervisor_client.backups.list.return_value = [TEST_BACKUP_3] - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3 - supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( - iter((b"backup data",)) - ) + supervisor_client.backups.backup_info.return_value = backup_info + supervisor_client.backups.download_backup.side_effect = SupervisorNotFoundError - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id={agent_id}") assert resp.status == 404 @@ -2026,14 +2033,22 @@ async def test_reader_writer_restore( @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code", "expected_reason"), + ("supervisor_error", "expected_error_code", "expected_reason"), [ - ("Invalid password for backup", "password_incorrect", "password_incorrect"), ( - "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", + SupervisorBadRequestError("Invalid password for backup"), + "password_incorrect", + "password_incorrect", + ), + ( + SupervisorBadRequestError( + "Backup was made on supervisor version 2025.12.0, can't " + "restore on 2024.12.0. Must update supervisor first." + ), "home_assistant_error", "unknown_error", ), + (SupervisorNotFoundError(), "backup_not_found", "backup_not_found"), ], ) @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @@ -2041,15 +2056,13 @@ async def test_reader_writer_restore_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, - supervisor_error_string: str, + supervisor_error: Exception, expected_error_code: str, expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( - supervisor_error_string - ) + supervisor_client.backups.partial_restore.side_effect = supervisor_error supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS From 37f0832c8b2d92d1f6a513ecc860e8d3a84494d7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 4 Feb 2025 17:54:05 +0100 Subject: [PATCH 143/178] Don't show active user initiated data entry config flows (#137334) Do not show active user initiated data entry config flows --- .../components/config/config_entries.py | 3 +- .../components/config/test_config_entries.py | 47 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 4a070a87734..52e3346002e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -302,7 +302,8 @@ def config_entries_progress( [ flw for flw in hass.config_entries.flow.async_progress() - if flw["context"]["source"] != config_entries.SOURCE_USER + if flw["context"]["source"] + not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER) ], ) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ee000c5ada2..f5241f65200 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -3,6 +3,7 @@ from collections import OrderedDict from collections.abc import Generator from http import HTTPStatus +from typing import Any from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient @@ -12,12 +13,13 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow +from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -729,27 +731,62 @@ async def test_get_progress_index( mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + class TestFlow(core_ce.ConfigFlow): VERSION = 5 - async def async_step_hassio(self, discovery_info): + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" return await self.async_step_account() - async def async_step_account(self, user_input=None): + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" return self.async_show_form(step_id="account") + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + with patch.dict(HANDLERS, {"test": TestFlow}): - form = await hass.config_entries.flow.async_init( + form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) + form_user = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_USER} + ) + form_reconfigure = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"} + ) + + for form in (form_hassio, form_user, form_reconfigure): + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["step_id"] == "account" await ws_client.send_json({"id": 5, "type": "config_entries/flow/progress"}) response = await ws_client.receive_json() assert response["success"] + + # Active flows with SOURCE_USER and SOURCE_RECONFIGURE should be filtered out assert response["result"] == [ { - "flow_id": form["flow_id"], + "flow_id": form_hassio["flow_id"], "handler": "test", "step_id": "account", "context": {"source": core_ce.SOURCE_HASSIO}, From 0463b90d36fbb45a6d8d56843c725597174a816f Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 4 Feb 2025 20:46:01 +0100 Subject: [PATCH 144/178] Fix HomeWizard reconfigure flow throwing error for v2-API devices (#137337) Fix reconfigure flow not working for v2 --- homeassistant/components/homewizard/config_flow.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index c94f590f000..6bcc51f939e 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -272,9 +272,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reconfiguration of the integration.""" errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: try: - device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect( + user_input[CONF_IP_ADDRESS], + token=reconfigure_entry.data.get(CONF_TOKEN), + ) except RecoverableError as ex: LOGGER.error(ex) @@ -288,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self._get_reconfigure_entry(), data_updates=user_input, ) - reconfigure_entry = self._get_reconfigure_entry() return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema( @@ -306,7 +310,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) -async def async_try_connect(ip_address: str) -> Device: +async def async_try_connect(ip_address: str, token: str | None = None) -> Device: """Try to connect. Make connection with device to test the connection @@ -317,7 +321,7 @@ async def async_try_connect(ip_address: str) -> Device: # Determine if device is v1 or v2 capable if await has_v2_api(ip_address): - energy_api = HomeWizardEnergyV2(ip_address) + energy_api = HomeWizardEnergyV2(ip_address, token=token) else: energy_api = HomeWizardEnergyV1(ip_address) From 0e9658b5ff23a0faf449f3531de9ee2548b17688 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 13:48:59 -0600 Subject: [PATCH 145/178] Copy area from remote parent device when creating Bluetooth devices (#137340) --- homeassistant/components/bluetooth/__init__.py | 13 ++++++------- tests/components/bluetooth/test_config_flow.py | 9 +++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c423e9e747b..c46ef22803e 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime import logging import platform -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import ( @@ -302,7 +302,6 @@ async def async_update_device( entry: ConfigEntry, adapter: str, details: AdapterDetails, - via_device_domain: str | None = None, via_device_id: str | None = None, ) -> None: """Update device registry entry. @@ -322,10 +321,11 @@ async def async_update_device( sw_version=details.get(ADAPTER_SW_VERSION), hw_version=details.get(ADAPTER_HW_VERSION), ) - if via_device_id: - device_registry.async_update_device( - device_entry.id, via_device_id=via_device_id - ) + if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)): + kwargs: dict[str, Any] = {"via_device_id": via_device_id} + if not device_entry.area_id and via_device_entry.area_id: + kwargs["area_id"] = via_device_entry.area_id + device_registry.async_update_device(device_entry.id, **kwargs) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -360,7 +360,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, source_entry.title, details, - source_domain, entry.data.get(CONF_SOURCE_DEVICE_ID), ) return True diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index b8f90b3a4aa..35c1ca1eafe 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.components.bluetooth.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.setup import async_setup_component from . import FakeRemoteScanner, MockBleakClient, _get_manager @@ -537,7 +537,9 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> @pytest.mark.usefixtures("enable_bluetooth") async def test_async_step_integration_discovery_remote_adapter( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, ) -> None: """Test remote adapter configuration via integration discovery.""" entry = MockConfigEntry(domain="test") @@ -547,10 +549,12 @@ async def test_async_step_integration_discovery_remote_adapter( ) scanner = FakeRemoteScanner("esp32", "esp32", connector, True) manager = _get_manager() + area_entry = area_registry.async_get_or_create("test") cancel_scanner = manager.async_register_scanner(scanner) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={("test", "BB:BB:BB:BB:BB:BB")}, + suggested_area=area_entry.id, ) result = await hass.config_entries.flow.async_init( @@ -585,6 +589,7 @@ async def test_async_step_integration_discovery_remote_adapter( ) assert ble_device_entry is not None assert ble_device_entry.via_device_id == device_entry.id + assert ble_device_entry.area_id == area_entry.id await hass.config_entries.async_unload(new_entry.entry_id) await hass.config_entries.async_unload(entry.entry_id) From c4b08d3d57ed183180bc3875b5ce8ae0bf5f955e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Feb 2025 21:02:28 +0100 Subject: [PATCH 146/178] Update frontend to 20250204.0 (#137342) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 93d5488be03..b584fe5e2f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250203.0"] + "requirements": ["home-assistant-frontend==20250204.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a23433f3333..5e22abb831b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250203.0 +home-assistant-frontend==20250204.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e240c5ffa31..005df1dbb0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250203.0 +home-assistant-frontend==20250204.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60891372a04..072ecd2f403 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250203.0 +home-assistant-frontend==20250204.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 1038a849c482f5bb8ee5e34e2bec17ebc375d356 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 12:24:42 -0600 Subject: [PATCH 147/178] Bump uiprotect to 7.5.1 (#137343) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 69c7f8b205b..a4bb6d20841 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.5.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 005df1dbb0e..1217b7534a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2941,7 +2941,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.0 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 072ecd2f403..86b49eddf77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2366,7 +2366,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.0 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From d7f0a55568ba29f8384ad80ebdbde704737e06e0 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Tue, 4 Feb 2025 13:27:46 -0500 Subject: [PATCH 148/178] Fix incorrect UPB service entity type (#137346) --- homeassistant/components/upb/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/upb/services.yaml b/homeassistant/components/upb/services.yaml index cf415705d72..985ce11c436 100644 --- a/homeassistant/components/upb/services.yaml +++ b/homeassistant/components/upb/services.yaml @@ -49,7 +49,7 @@ link_deactivate: target: entity: integration: upb - domain: light + domain: scene link_goto: target: From 14034ed7f8d614746d0d5e806abcb91016857d3d Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 4 Feb 2025 21:03:28 +0100 Subject: [PATCH 149/178] Polish tplink vacuum sensors (#137355) --- homeassistant/components/tplink/sensor.py | 19 +++++++++ .../tplink/snapshots/test_sensor.ambr | 41 +++++++++---------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 38aab26cf8b..9b21ba775a9 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -135,13 +135,17 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="clean_area", device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.MEASUREMENT, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="clean_progress", + state_class=SensorStateClass.MEASUREMENT, ), TPLinkSensorEntityDescription( key="last_clean_time", device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, convert_fn=_TOTAL_SECONDS_METHOD_CALLER, @@ -155,20 +159,26 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="total_clean_time", device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="total_clean_area", device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.TOTAL_INCREASING, ), TPLinkSensorEntityDescription( key="total_clean_count", + state_class=SensorStateClass.TOTAL_INCREASING, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="main_brush_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -176,6 +186,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="main_brush_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -183,6 +194,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="side_brush_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -190,6 +202,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="side_brush_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -197,6 +210,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="filter_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -204,6 +218,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="filter_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -211,6 +226,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="sensor_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -218,6 +234,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="sensor_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -225,6 +242,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="charging_contacts_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -232,6 +250,7 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( convert_fn=_TOTAL_SECONDS_METHOD_CALLER, ), TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, key="charging_contacts_used", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 0d1cc9a03e4..093b92ef315 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -243,7 +243,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -279,6 +281,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'area', 'friendly_name': 'my_device Cleaning area', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -294,11 +297,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , - 'disabled_by': None, + 'disabled_by': , 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.my_device_cleaning_progress', @@ -322,20 +327,6 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_cleaning_progress-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Cleaning progress', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_device_cleaning_progress', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30', - }) -# --- # name: test_states[sensor.my_device_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -801,7 +792,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1426,7 +1419,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1462,7 +1457,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -1495,7 +1492,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , From af40bb39ad4538b495b54d53b28f822e00427b0a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 4 Feb 2025 22:12:18 +0200 Subject: [PATCH 150/178] Bump aranet4 to 2.5.1 (#137359) --- homeassistant/components/aranet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index ac45e352bb6..3131b00cda6 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.5.0"] + "requirements": ["aranet4==2.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1217b7534a2..4efdf404339 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -491,7 +491,7 @@ apsystems-ez1==2.4.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86b49eddf77..e5666f15586 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -461,7 +461,7 @@ aprslib==0.7.2 apsystems-ez1==2.4.0 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj arcam-fmj==1.5.2 From 1a5b8cf854d094aabb2af20f0fb1c1800f20a8f1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Feb 2025 21:34:21 +0100 Subject: [PATCH 151/178] Bump deebot-client to 12.0.0 (#137361) --- 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 7b05162867b..33a251c22dc 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.0b0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4efdf404339..79545b412aa 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.0b0 +deebot-client==12.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5666f15586..fe8afaec7c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.32.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.0.0b0 +deebot-client==12.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 1c8ced2c2d307787854990ea6d5b2f3c2ca8ad8b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 4 Feb 2025 22:13:50 +0200 Subject: [PATCH 152/178] Fix Tado missing await (#137364) --- homeassistant/components/tado/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index c8eaec76255..db7b1823bd9 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -506,7 +506,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): offset, ) - self._tado.set_temperature_offset(self._device_id, offset) + await self._tado.set_temperature_offset(self._device_id, offset) await self.coordinator.async_request_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: From b5e4fee9aae317eb9b0affee7629d8fb9a7e0c55 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Feb 2025 21:42:50 +0100 Subject: [PATCH 153/178] Bump version to 2025.2.0b9 --- 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 542b034bd79..db5f31c2846 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 = "0b8" +PATCH_VERSION: Final = "0b9" __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 2eb93d28b27..8c60242b9fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b8" +version = "2025.2.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3dc075f2877340f89d3e7dbe2315630f27f9302a Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 5 Feb 2025 19:43:38 +1100 Subject: [PATCH 154/178] Bump pysmlight to v0.1.7 (#137390) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 63bae37f431..4bc2f36dddf 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.6"], + "requirements": ["pysmlight==0.1.7"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 79545b412aa..645bd3eb3af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.1.7 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe8afaec7c0..15b182d75a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.1.7 # homeassistant.components.snmp pysnmp==6.2.6 From d8179dacc66f2805be869413cb8bc7d9224e77b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 13:55:36 +0100 Subject: [PATCH 155/178] Report progress while restoring supervisor backup (#137313) --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 32 +++- tests/components/hassio/test_backup.py | 187 +++++++++++++++++++- 3 files changed, 214 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3ee6d2026f9..71a4f5ea41a 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -33,6 +33,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupStage, RestoreBackupState, WrittenBackup, ) @@ -61,6 +62,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", "async_get_manager", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index dfc161bd4e7..142c5fc01ce 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -39,6 +39,7 @@ from homeassistant.components.backup import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupStage, RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, @@ -548,6 +549,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" + if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown restore stage: %s", data.get("stage")) + else: + on_progress( + RestoreBackupEvent( + reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS + ) + ) if data.get("done") is True: restore_complete.set() restore_errors.extend(data.get("errors", [])) @@ -574,15 +583,26 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + sent_event = False + @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" + nonlocal sent_event + + if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown restore stage: %s", data.get("stage")) + if data.get("done") is not True: - on_progress( - RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + if stage or not sent_event: + sent_event = True + on_progress( + RestoreBackupEvent( + reason=None, + stage=stage, + state=RestoreBackupState.IN_PROGRESS, + ) ) - ) return restore_errors = data.get("errors", []) @@ -592,14 +612,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress( RestoreBackupEvent( reason="unknown_error", - stage=None, + stage=stage, state=RestoreBackupState.FAILED, ) ) else: on_progress( RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.COMPLETED + reason=None, stage=stage, state=RestoreBackupState.COMPLETED ) ) on_progress(IdleEvent()) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 459ebe581fb..cc62e77ea22 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2032,6 +2032,109 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + @pytest.mark.parametrize( ("supervisor_error", "expected_error_code", "expected_reason"), [ @@ -2261,7 +2364,7 @@ async def test_reader_writer_restore_wrong_parameters( TEST_JOB_DONE, { "manager_state": "restore_backup", - "reason": "", + "reason": None, "stage": None, "state": "completed", }, @@ -2302,6 +2405,88 @@ async def test_restore_progress_after_restart( assert response["result"]["state"] == "idle" +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + response = await client.receive_json() + assert response["success"] + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = ["addon_repositories", "home_assistant", "addons"] + expected_manager_states = ["in_progress", "in_progress", "completed"] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": expected_manager_states[i], + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": "addons", + "state": "completed", + } + assert response["result"]["state"] == "idle" + + @pytest.mark.usefixtures("hassio_client") async def test_restore_progress_after_restart_unknown_job( hass: HomeAssistant, From 16d9270833023eaabeffb8a924acb24b713973a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 02:29:23 -0600 Subject: [PATCH 156/178] Fix memory leak when unloading DataUpdateCoordinator (#137338) * check wiz * Fix memory leak when unloading DataUpdateCoordinator fixes #137237 * handle namespace conflict * handle namespace conflict * address review comments --- homeassistant/helpers/update_coordinator.py | 29 +++++++++++++-------- tests/components/homewizard/test_init.py | 5 ++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 943eadff19a..be765ff422d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,6 +6,7 @@ from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Generator from datetime import datetime, timedelta +from functools import partial import logging from random import randint from time import monotonic @@ -103,7 +104,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) - self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listeners: dict[int, tuple[CALLBACK_TYPE, object | None]] = {} + self._last_listener_id: int = 0 self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -148,21 +150,26 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._listeners.pop(remove_listener) - if not self._listeners: - self._unschedule_refresh() - - self._listeners[remove_listener] = (update_callback, context) + self._last_listener_id += 1 + self._listeners[self._last_listener_id] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - return remove_listener + return partial(self.__async_remove_listener_internal, self._last_listener_id) + + @callback + def __async_remove_listener_internal(self, listener_id: int) -> None: + """Remove a listener. + + This is an internal function that is not to be overridden + in subclasses as it may change in the future. + """ + self._listeners.pop(listener_id) + if not self._listeners: + self._unschedule_refresh() + self._debounced_refresh.async_cancel() @callback def async_update_listeners(self) -> None: diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 412ddb13eda..9139ef80d12 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock +import weakref from freezegun.api import FrozenDateTimeFactory from homewizard_energy.errors import DisabledError, UnauthorizedError @@ -25,6 +26,9 @@ async def test_load_unload_v1( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + weak_ref = weakref.ref(mock_config_entry.runtime_data) + assert weak_ref() is not None + assert mock_config_entry.state is ConfigEntryState.LOADED assert len(mock_homewizardenergy.combined.mock_calls) == 1 @@ -32,6 +36,7 @@ async def test_load_unload_v1( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert weak_ref() is None async def test_load_unload_v2( From 2c5fd4ee2a0bb6c7f7e011c12a82fc9b21d4ce1a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:59:59 +0100 Subject: [PATCH 157/178] Update led-ble to 1.1.5 (#137347) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 8608c0b2798..9a65f62202b 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 645bd3eb3af..5fc109bbae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.5 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15b182d75a7..ede0bf50df3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1098,7 +1098,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.5 # homeassistant.components.lektrico lektricowifi==0.0.43 From da8d300f291880a9d20a541578cf946bcc289a07 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 4 Feb 2025 22:31:05 +0100 Subject: [PATCH 158/178] Fix sqlalchemy deprecation warning that `declarative_base` has moved (#137360) --- tests/components/recorder/db_schema_9.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index 784e326e1c3..6cf7085e279 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -19,8 +19,7 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id From df2b29aef1ab1b64c59dd1df9f671da3ab61163c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 15:37:33 -0600 Subject: [PATCH 159/178] Bump led-ble to 1.1.6 (#137369) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 9a65f62202b..ff620da1993 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.5"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5fc109bbae8..ea62f996179 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.5 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ede0bf50df3..4a19b59a352 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1098,7 +1098,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.5 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 From fa83591148a6ab8474a6676f6263ad3a769452de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 17:11:05 -0600 Subject: [PATCH 160/178] Allow ignored Bluetooth adapters to be set up from the user flow (#137373) --- .../components/bluetooth/config_flow.py | 6 +---- .../components/bluetooth/strings.json | 2 +- .../components/bluetooth/test_config_flow.py | 22 ++++++++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 5d03a9c9d0f..e76277306f5 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -140,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): title=adapter_title(adapter, details), data={} ) - configured_addresses = self._async_current_ids() + configured_addresses = self._async_current_ids(include_ignore=False) bluetooth_adapters = get_adapters() await bluetooth_adapters.refresh() self._adapters = bluetooth_adapters.adapters @@ -155,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) ] if not unconfigured_adapters: - ignored_adapters = len( - self._async_current_entries(include_ignore=True) - ) - len(self._async_current_entries(include_ignore=False)) return self.async_abort( reason="no_adapters", - description_placeholders={"ignored_adapters": str(ignored_adapters)}, ) if len(unconfigured_adapters) == 1: self._adapter = list(self._adapters)[0] diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 5f9a380d631..866b76c0985 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -23,7 +23,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." + "no_adapters": "No unconfigured Bluetooth adapters found." } }, "options": { diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 35c1ca1eafe..f0136396c22 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -517,8 +517,10 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non @pytest.mark.usefixtures("one_adapter") -async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: - """Test we give a hint that the adapter is ignored.""" +async def test_async_step_user_linux_adapter_replace_ignored( + hass: HomeAssistant, +) -> None: + """Test we can replace an ignored adapter from user flow.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="00:00:00:00:00:01", @@ -530,9 +532,19 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> context={"source": config_entries.SOURCE_USER}, data={}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_adapters" - assert result["description_placeholders"] == {"ignored_adapters": "1"} + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("enable_bluetooth") From 0764c7e7739cb7c5707a3432f9daba28c7875bfc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:09:33 +1000 Subject: [PATCH 161/178] Bump Tesla Fleet API to v0.9.8 (#137379) * v0.9.7 * v0.9.8 --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index fa0f336eb18..330745316d7 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6"] + "requirements": ["tesla-fleet-api==0.9.8"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 09cab85dfe4..136990e5347 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6", "teslemetry-stream==0.6.6"] + "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.6"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index f6015b0ef4e..ef4d366c779 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.6"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index ea62f996179..7fd95d08408 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2854,7 +2854,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a19b59a352..df91300646e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2294,7 +2294,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 79563f374669b51b3c75deb4032c7bdc335736b3 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:43:22 +1000 Subject: [PATCH 162/178] Handle powerwall at zero percent in Tesla Fleet and Tessie (#137393) * Handle powerwall zero * Add missing value_fn call --- .../components/tesla_fleet/sensor.py | 29 ++++++++++--------- homeassistant/components/tessie/sensor.py | 2 ++ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 3e05e7e723b..c1d38bf85c5 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -303,8 +303,8 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslaFleetTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = ( + TeslaFleetSensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -312,7 +312,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -321,7 +321,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -331,14 +331,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -346,7 +347,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -354,7 +355,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -362,7 +363,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -370,7 +371,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -379,7 +380,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="island_status", options=[ "island_status_unknown", @@ -550,12 +551,12 @@ class TeslaFleetVehicleTimeSensorEntity(TeslaFleetVehicleEntity, SensorEntity): class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity): """Base class for Tesla Fleet energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslaFleetSensorEntityDescription def __init__( self, data: TeslaFleetEnergyData, - description: SensorEntityDescription, + description: TeslaFleetSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -563,7 +564,7 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 7f09cef2acd..323fa76ef1f 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -258,6 +258,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), ) + ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -292,6 +293,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), TessieSensorEntityDescription( key="battery_power", From c506c9080aafebf9d22a52c3d7e8583501ffe8a5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 05:42:41 -0500 Subject: [PATCH 163/178] Simplify llm calendar tool (#137402) * Simplify calendar tool * Clean up exposed entities --- .../components/mcp_server/llm_api.py | 4 +- homeassistant/helpers/llm.py | 69 +++++++++++-------- tests/helpers/test_llm.py | 15 +++- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py index f4292744815..5c29b29153e 100644 --- a/homeassistant/components/mcp_server/llm_api.py +++ b/homeassistant/components/mcp_server/llm_api.py @@ -35,13 +35,13 @@ class StatelessAssistAPI(llm.AssistAPI): """Return the prompt for the exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) entities = [ {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities.values() + for entity_info in exposed_entities["entities"].values() ] prompt.append(yaml_util.dump(list(entities))) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2bca4c8528b..b330494a1b8 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -326,7 +326,7 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - if not exposed_entities: + if not exposed_entities or not exposed_entities["entities"]: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." @@ -389,11 +389,11 @@ class AssistAPI(API): """Return the prompt for the API for exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml_util.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) return prompt @@ -425,8 +425,9 @@ class AssistAPI(API): exposed_domains: set[str] | None = None if exposed_entities is not None: exposed_domains = { - split_entity_id(entity_id)[0] for entity_id in exposed_entities + info["domain"] for info in exposed_entities["entities"].values() } + intent_handlers = [ intent_handler for intent_handler in intent_handlers @@ -438,25 +439,29 @@ class AssistAPI(API): IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) for intent_handler in intent_handlers ] - if exposed_domains and CALENDAR_DOMAIN in exposed_domains: - tools.append(CalendarGetEventsTool()) - if llm_context.assistant is not None: - for state in self.hass.states.async_all(SCRIPT_DOMAIN): - if not async_should_expose( - self.hass, llm_context.assistant, state.entity_id - ): - continue + if exposed_entities: + if exposed_entities[CALENDAR_DOMAIN]: + names = [] + for info in exposed_entities[CALENDAR_DOMAIN].values(): + names.extend(info["names"].split(", ")) + tools.append(CalendarGetEventsTool(names)) - tools.append(ScriptTool(self.hass, state.entity_id)) + tools.extend( + ScriptTool(self.hass, script_entity_id) + for script_entity_id in exposed_entities[SCRIPT_DOMAIN] + ) return tools def _get_exposed_entities( hass: HomeAssistant, assistant: str -) -> dict[str, dict[str, Any]]: - """Get exposed entities.""" +) -> dict[str, dict[str, dict[str, Any]]]: + """Get exposed entities. + + Splits out calendars and scripts. + """ area_registry = ar.async_get(hass) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -477,12 +482,13 @@ def _get_exposed_entities( } entities = {} + data: dict[str, dict[str, Any]] = { + SCRIPT_DOMAIN: {}, + CALENDAR_DOMAIN: {}, + } for state in hass.states.async_all(): - if ( - not async_should_expose(hass, assistant, state.entity_id) - or state.domain == SCRIPT_DOMAIN - ): + if not async_should_expose(hass, assistant, state.entity_id): continue description: str | None = None @@ -529,9 +535,13 @@ def _get_exposed_entities( }: info["attributes"] = attributes - entities[state.entity_id] = info + if state.domain in data: + data[state.domain][state.entity_id] = info + else: + entities[state.entity_id] = info - return entities + data["entities"] = entities + return data def _selector_serializer(schema: Any) -> Any: # noqa: C901 @@ -813,15 +823,18 @@ class CalendarGetEventsTool(Tool): name = "calendar_get_events" description = ( "Get events from a calendar. " - "When asked when something happens, search the whole week. " + "When asked if something happens, search the whole week. " "Results are RFC 5545 which means 'end' is exclusive." ) - parameters = vol.Schema( - { - vol.Required("calendar"): cv.string, - vol.Required("range"): vol.In(["today", "week"]), - } - ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index e288026b67b..630ed3f4fa1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1170,7 +1170,9 @@ async def test_selector_serializer( async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool.""" assert await async_setup_component(hass, "homeassistant", {}) - hass.states.async_set("calendar.test_calendar", "on", {"friendly_name": "Test"}) + hass.states.async_set( + "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} + ) async_expose_entity(hass, "conversation", "calendar.test_calendar", True) context = Context() llm_context = llm.LLMContext( @@ -1182,7 +1184,11 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool for tool in api.tools if tool.name == "calendar_get_events"] + tool = next( + (tool for tool in api.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] calls = async_mock_service( hass, @@ -1212,7 +1218,10 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: tool_input = llm.ToolInput( tool_name="calendar_get_events", - tool_args={"calendar": "calendar.test_calendar", "range": "today"}, + tool_args={ + "calendar": "Mock Calendar Name", + "range": "today", + }, ) now = dt_util.now() with patch("homeassistant.util.dt.now", return_value=now): From 30c099ef4e19fd89c6e145ac7533c4baafb5e2aa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 10:14:39 +0100 Subject: [PATCH 164/178] Allow creating backup if at least one agent is available (#137409) --- homeassistant/components/backup/manager.py | 61 +++++++++++++----- tests/components/backup/test_manager.py | 75 ++++++++++++++++++++-- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fa9ca956c22..24639ad4008 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io +from itertools import chain import json from pathlib import Path, PurePath import shutil @@ -827,7 +828,7 @@ class BackupManager: password=None, ) await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add(written_backup.backup, agent_errors, []) return written_backup.backup.backup_id async def async_create_backup( @@ -951,12 +952,23 @@ class BackupManager: with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" - if not agent_ids: - raise BackupManagerError("At least one agent must be selected") - if invalid_agents := [ + unavailable_agents = [ agent_id for agent_id in agent_ids if agent_id not in self.backup_agents - ]: - raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") + ] + if not ( + available_agents := [ + agent_id for agent_id in agent_ids if agent_id in self.backup_agents + ] + ): + raise BackupManagerError( + f"At least one available backup agent must be selected, got {agent_ids}" + ) + if unavailable_agents: + LOGGER.warning( + "Backup agents %s are not available, will backupp to %s", + unavailable_agents, + available_agents, + ) if include_all_addons and include_addons: raise BackupManagerError( "Cannot include all addons and specify specific addons" @@ -973,7 +985,7 @@ class BackupManager: new_backup, self._backup_task, ) = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, + agent_ids=available_agents, backup_name=backup_name, extra_metadata=extra_metadata | { @@ -992,7 +1004,9 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings, password), + self._async_finish_backup( + available_agents, unavailable_agents, with_automatic_settings, password + ), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -1009,7 +1023,11 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool, password: str | None + self, + available_agents: list[str], + unavailable_agents: list[str], + with_automatic_settings: bool, + password: str | None, ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -1028,7 +1046,7 @@ class BackupManager: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", written_backup.backup.backup_id, - agent_ids, + available_agents, ) self.async_on_backup_event( CreateBackupEvent( @@ -1041,13 +1059,15 @@ class BackupManager: try: agent_errors = await self._async_upload_backup( backup=written_backup.backup, - agent_ids=agent_ids, + agent_ids=available_agents, open_stream=written_backup.open_stream, password=password, ) finally: await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add( + written_backup.backup, agent_errors, unavailable_agents + ) if not agent_errors: if with_automatic_settings: # create backup was successful, update last_completed_automatic_backup @@ -1056,7 +1076,7 @@ class BackupManager: backup_success = True if with_automatic_settings: - self._update_issue_after_agent_upload(agent_errors) + self._update_issue_after_agent_upload(agent_errors, unavailable_agents) # delete old backups more numerous than copies # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) @@ -1216,10 +1236,10 @@ class BackupManager: ) def _update_issue_after_agent_upload( - self, agent_errors: dict[str, Exception] + self, agent_errors: dict[str, Exception], unavailable_agents: list[str] ) -> None: """Update issue registry after a backup is uploaded to agents.""" - if not agent_errors: + if not agent_errors and not unavailable_agents: ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") return ir.async_create_issue( @@ -1233,7 +1253,13 @@ class BackupManager: translation_key="automatic_backup_failed_upload_agents", translation_placeholders={ "failed_agents": ", ".join( - self.backup_agents[agent_id].name for agent_id in agent_errors + chain( + ( + self.backup_agents[agent_id].name + for agent_id in agent_errors + ), + unavailable_agents, + ) ) }, ) @@ -1302,11 +1328,12 @@ class KnownBackups: self, backup: AgentBackup, agent_errors: dict[str, Exception], + unavailable_agents: list[str], ) -> None: """Add a backup.""" self._backups[backup.backup_id] = KnownBackup( backup_id=backup.backup_id, - failed_agent_ids=list(agent_errors), + failed_agent_ids=list(chain(agent_errors, unavailable_agents)), ) self._manager.store.save() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 57f11ed4708..aa7d7ebd95c 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -359,8 +359,14 @@ async def test_create_backup_when_busy( @pytest.mark.parametrize( ("parameters", "expected_error"), [ - ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"agent_ids": []}, + "At least one available backup agent must be selected, got []", + ), + ( + {"agent_ids": ["non_existing"]}, + "At least one available backup agent must be selected, got ['non_existing']", + ), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", @@ -410,6 +416,8 @@ async def test_create_backup_wrong_parameters( "name", "expected_name", "expected_filename", + "expected_agent_ids", + "expected_failed_agent_ids", "temp_file_unlink_call_count", ), [ @@ -419,6 +427,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -427,6 +437,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -435,6 +447,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], 0, ), ( @@ -443,6 +457,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -451,6 +467,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -459,6 +477,19 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + # Test we create a backup when at least one agent is available + [LOCAL_AGENT_ID, "test.unavailable"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + ["test.unavailable"], 0, ), ], @@ -486,6 +517,8 @@ async def test_initiate_backup( name: str | None, expected_name: str, expected_filename: str, + expected_agent_ids: list[str], + expected_failed_agent_ids: list[str], temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -620,13 +653,13 @@ async def test_initiate_backup( "addons": [], "agents": { agent_id: {"protected": bool(password), "size": ANY} - for agent_id in agent_ids + for agent_id in expected_agent_ids }, "backup_id": backup_id, "database_included": include_database, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, - "failed_agent_ids": [], + "failed_agent_ids": expected_failed_agent_ids, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -959,6 +992,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: @pytest.mark.parametrize( ( + "automatic_agents", "create_backup_command", "create_backup_side_effect", "agent_upload_side_effect", @@ -968,6 +1002,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: [ # No error ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, None, @@ -975,14 +1010,38 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, None, True, {}, ), + # One agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + None, + None, + True, + {}, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.unknown"}, + } + }, + ), # Error raised in async_initiate_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, Exception("Boom!"), None, @@ -990,6 +1049,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, Exception("Boom!"), None, @@ -1003,6 +1063,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised when awaiting the backup task ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, delayed_boom, None, @@ -1010,6 +1071,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, delayed_boom, None, @@ -1023,6 +1085,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised in async_upload_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, Exception("Boom!"), @@ -1030,6 +1093,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, Exception("Boom!"), @@ -1047,6 +1111,7 @@ async def test_create_backup_failure_raises_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, create_backup: AsyncMock, + automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, agent_upload_side_effect: Exception | None, @@ -1077,7 +1142,7 @@ async def test_create_backup_failure_raises_issue( await ws_client.send_json_auto_id( { "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.remote"]}, + "create_backup": {"agent_ids": automatic_agents}, } ) result = await ws_client.receive_json() From e8314fb286b9ff9fb6356f71bed52dc66ead07d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:14:06 +0100 Subject: [PATCH 165/178] Adjust logic for per-backup agent encryption (#137420) --- homeassistant/components/backup/manager.py | 6 ++- tests/components/backup/test_manager.py | 55 +++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 24639ad4008..25393a872cc 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1439,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] agent_config = manager.config.data.agents.get(self._local_agent_id) - if agent_config and not agent_config.protected: + if ( + self._local_agent_id in agent_ids + and agent_config + and not agent_config.protected + ): password = None backup = AgentBackup( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index aa7d7ebd95c..beaf942b21f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -46,6 +46,7 @@ from homeassistant.components.backup.manager import ( RestoreBackupState, WrittenBackup, ) +from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -3207,17 +3208,21 @@ async def test_restore_backup_file_error( @pytest.mark.parametrize( - ("commands", "password", "protected_backup"), + ("commands", "agent_ids", "password", "protected_backup", "inner_tar_key"), [ ( [], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, ), ( [], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3229,8 +3234,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": False}, + None, # None of the agents are protected ), ( [ @@ -3242,8 +3249,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": True}, + None, # Local agent is not protected ), ( [ @@ -3255,8 +3264,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": False}, + password_to_key("hunter2"), # Local agent is protected ), ( [ @@ -3268,8 +3279,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3281,8 +3294,40 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, # No password supplied + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": False}, + password_to_key("hunter2"), # Temporary backup protected when password set ), ], ) @@ -3291,13 +3336,15 @@ async def test_initiate_backup_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, + mocked_tarfile: Mock, path_glob: MagicMock, commands: dict[str, Any], + agent_ids: list[str], password: str | None, protected_backup: dict[str, bool], + inner_tar_key: bytes | None, ) -> None: """Test generate backup where encryption is selectively set on agents.""" - agent_ids = ["backup.local", "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -3373,6 +3420,10 @@ async def test_initiate_backup_per_agent_encryption( await hass.async_block_till_done() + mocked_tarfile.return_value.create_inner_tar.assert_called_once_with( + ANY, gzip=True, key=inner_tar_key + ) + result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, From 72a69d7e41ec54d18e90918b38802991e3bc93bb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:02:58 +0100 Subject: [PATCH 166/178] Adjust backup filename scheme (#137424) * Adjust backup filename scheme * Update tests --- homeassistant/components/backup/util.py | 2 +- tests/components/backup/test_backup.py | 4 +--- tests/components/backup/test_manager.py | 14 +++++++------- tests/components/backup/test_util.py | 8 ++++---- .../google_drive/snapshots/test_backup.ambr | 4 ++-- tests/components/hassio/test_backup.py | 4 ++-- tests/components/synology_dsm/test_backup.py | 6 +++--- 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index b920c66a9b8..9d8f6e815dc 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -122,7 +122,7 @@ def read_backup(backup_path: Path) -> AgentBackup: def suggested_filename_from_name_date(name: str, date_str: str) -> str: """Suggest a filename for the backup.""" date = dt_util.parse_datetime(date_str, raise_on_error=True) - return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + return "_".join(f"{name} {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) def suggested_filename(backup: AgentBackup) -> str: diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index c441cae292c..38b61ce65ea 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,9 +103,7 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert ( - move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" - ) + assert move_mock.mock_calls[0].args[1].name == "Test_1970-01-01_00.00_00000000.tar" @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index beaf942b21f..bdcb9f068b6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -427,7 +427,7 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], [], 0, @@ -447,7 +447,7 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID, "test.remote"], [], 0, @@ -457,7 +457,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], [], 0, @@ -477,7 +477,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID, "test.remote"], [], 0, @@ -488,7 +488,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], ["test.unavailable"], 0, @@ -1677,7 +1677,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1686,7 +1686,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 3b188ff8226..504e0d56d58 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -529,10 +529,10 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("name", "resulting_filename"), [ - ("test", "test_-_2025-01-30_13.42_12345678.tar"), - (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), - ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), - ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ("test", "test_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_2025-01-30_13.42_12345678.tar"), ], ) def test_suggested_filename(name: str, resulting_filename: str) -> None: diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 9e1ec00b52e..2f3df3eed7f 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -140,7 +140,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'HA folder ID', ]), @@ -211,7 +211,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'new folder id', ]), diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index cc62e77ea22..89a789cdc50 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -887,7 +887,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), + filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -1400,7 +1400,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 26e09d407ff..ea68bbc991c 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -36,7 +36,7 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator -BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_2025-01-09_20.14_35457323" class MockStreamReaderChunked(MockStreamReader): @@ -525,7 +525,7 @@ async def test_agents_upload( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" with ( patch( @@ -576,7 +576,7 @@ async def test_agents_upload_error( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" # fail to upload the tar file with ( From a4474b2794322292a141618ebd87bf5a6cadc8f9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 12:26:27 +0000 Subject: [PATCH 167/178] Bump version to 2025.2.0b10 --- 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 db5f31c2846..c35dbcaa378 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 = "0b9" +PATCH_VERSION: Final = "0b10" __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 8c60242b9fd..740d7d7bc3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b9" +version = "2025.2.0b10" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5687a4d7185c20d23f3ed8767716318bdd37012d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Feb 2025 15:26:58 +0100 Subject: [PATCH 168/178] Bump onedrive to 0.0.8 (#137423) * Bump onedrive to 0.0.6 * bump to 0.0.7 * bump to 0.0.8 * Improve coverage --- homeassistant/components/onedrive/__init__.py | 14 +++++--- homeassistant/components/onedrive/api.py | 34 ------------------- homeassistant/components/onedrive/backup.py | 4 +-- .../components/onedrive/config_flow.py | 9 +++-- .../components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/onedrive/conftest.py | 30 +++++++++------- tests/components/onedrive/test_config_flow.py | 5 +++ tests/components/onedrive/test_init.py | 5 +++ 10 files changed, 46 insertions(+), 61 deletions(-) delete mode 100644 homeassistant/components/onedrive/api.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index ef7ddd04da6..5feefb2cf7d 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( @@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,7 +25,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .api import OneDriveConfigEntryAccessTokenProvider from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -31,7 +33,7 @@ class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" client: OneDriveClient - token_provider: OneDriveConfigEntryAccessTokenProvider + token_function: Callable[[], Awaitable[str]] backup_folder_id: str @@ -46,9 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - token_provider = OneDriveConfigEntryAccessTokenProvider(session) + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) - client = OneDriveClient(token_provider, async_get_clientsession(hass)) + client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: @@ -81,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.runtime_data = OneDriveRuntimeData( client=client, - token_provider=token_provider, + token_function=get_access_token, backup_folder_id=backup_folder.id, ) diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py deleted file mode 100644 index d8f6ea188f3..00000000000 --- a/homeassistant/components/onedrive/api.py +++ /dev/null @@ -1,34 +0,0 @@ -"""API for OneDrive bound to Home Assistant OAuth.""" - -from typing import cast - -from onedrive_personal_sdk import TokenProvider - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow - - -class OneDriveConfigFlowAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, token: str) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._token = token - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return self._token - - -class OneDriveConfigEntryAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._oauth_session = oauth_session - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 43eac020538..78bdcb24b8c 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -109,7 +109,7 @@ class OneDriveBackupAgent(BackupAgent): self._hass = hass self._entry = entry self._client = entry.runtime_data.client - self._token_provider = entry.runtime_data.token_provider + self._token_function = entry.runtime_data.token_function self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -145,7 +145,7 @@ class OneDriveBackupAgent(BackupAgent): ) try: item = await LargeFileUploadClient.upload( - self._token_provider, file, session=async_get_clientsession(self._hass) + self._token_function, file, session=async_get_clientsession(self._hass) ) except HashMismatchError as err: raise BackupAgentError( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index cbdf59648b9..900db0177d9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -12,7 +12,6 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -36,12 +35,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - token_provider = OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) + + async def get_access_token() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) graph_client = OneDriveClient( - token_provider, async_get_clientsession(self.hass) + get_access_token, async_get_clientsession(self.hass) ) try: diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 47eb48742be..88d51e6d73a 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.4"] + "requirements": ["onedrive-personal-sdk==0.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7fd95d08408..5dd70c1db86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df91300646e..8fb98c9ff57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index e76ce1d01c8..0d6ee09d587 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -67,8 +67,8 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture(autouse=True) -def mock_onedrive_client() -> Generator[MagicMock]: +@pytest.fixture +def mock_onedrive_client_init() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( @@ -80,19 +80,25 @@ def mock_onedrive_client() -> Generator[MagicMock]: new=onedrive_client, ), ): - client = onedrive_client.return_value - client.get_approot.return_value = MOCK_APPROOT - client.create_folder.return_value = MOCK_BACKUP_FOLDER - client.list_drive_items.return_value = [MOCK_BACKUP_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + yield onedrive_client - class MockStreamReader: - async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: - yield b"backup data" - client.download_drive_item.return_value = MockStreamReader() +@pytest.fixture(autouse=True) +def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + client = mock_onedrive_client_init.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - yield client + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_drive_item.return_value = MockStreamReader() + + return client @pytest.fixture diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 9acfd8ada3c..fb0d58b86c6 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -70,6 +70,7 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, + mock_onedrive_client_init: MagicMock, ) -> None: """Check full flow.""" @@ -79,6 +80,10 @@ async def test_full_flow( await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 674bc2d38d9..a6ad55442aa 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -16,10 +16,15 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_onedrive_client_init: MagicMock, ) -> None: """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) From c4e2ddd28b8f1949aaa77dd8569ab36fdbc6f0ab Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 5 Feb 2025 13:48:12 +0100 Subject: [PATCH 169/178] Bump reolink_aio to 0.11.9 (#137430) * Add push callbacks * Bump reolink_aio to 0.11.9 --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 5 +++++ homeassistant/components/reolink/select.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 83729fef3cd..fb3c096ee41 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.8"] + "requirements": ["reolink-aio==0.11.9"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e4b52c85d45..d8fabfaa3b8 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -424,6 +424,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_brightness", cmd_key="GetImage", + cmd_id=26, translation_key="image_brightness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -437,6 +438,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_contrast", cmd_key="GetImage", + cmd_id=26, translation_key="image_contrast", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -450,6 +452,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_saturation", cmd_key="GetImage", + cmd_id=26, translation_key="image_saturation", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -463,6 +466,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_sharpness", cmd_key="GetImage", + cmd_id=26, translation_key="image_sharpness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -476,6 +480,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_hue", cmd_key="GetImage", + cmd_id=26, translation_key="image_hue", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7a74be2e28c..df8c0269957 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -80,6 +80,7 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="day_night_mode", cmd_key="GetIsp", + cmd_id=26, translation_key="day_night_mode", entity_category=EntityCategory.CONFIG, get_options=[mode.name for mode in DayNightEnum], diff --git a/requirements_all.txt b/requirements_all.txt index 5dd70c1db86..036f76aa876 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,7 +2603,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fb98c9ff57..f7a34a729fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2106,7 +2106,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.9 # homeassistant.components.rflink rflink==0.0.66 From d6414b98499b034615558a5cc1142dcd020e30dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 16:58:39 +0100 Subject: [PATCH 170/178] Bump aiohasupervisor to version 0.3.0 (#137437) --- homeassistant/components/hassio/backup.py | 37 ++--- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_backup.py | 134 ++++++------------ 8 files changed, 71 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 142c5fc01ce..ddaa821587f 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -20,6 +20,7 @@ from aiohasupervisor.models import ( backups as supervisor_backups, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from homeassistant.components.backup import ( DATA_MANAGER, @@ -56,8 +57,6 @@ from homeassistant.util.enum import try_parse_enum from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client -LOCATION_CLOUD_BACKUP = ".cloud_backup" -LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" # Set on backups automatically created when updating an addon @@ -72,7 +71,9 @@ async def async_get_backup_agents( """Return the hassio backup agents.""" client = get_supervisor_client(hass) mounts = await client.mounts.info() - agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] + agents: list[BackupAgent] = [ + SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE) + ] for mount in mounts.mounts: if mount.usage is not supervisor_mounts.MountUsage.BACKUP: continue @@ -112,7 +113,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, location: str | None + details: supervisor_backups.BackupComplete, location: str ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -125,7 +126,6 @@ def _backup_details_to_agent_backup( for addon in details.addons ] extra_metadata = details.extra or {} - location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -148,7 +148,7 @@ class SupervisorBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: + def __init__(self, hass: HomeAssistant, name: str, location: str) -> None: """Initialize the backup agent.""" super().__init__() self._hass = hass @@ -206,7 +206,7 @@ class SupervisorBackupAgent(BackupAgent): backup_list = await self._client.backups.list() result = [] for backup in backup_list: - if not backup.locations or self.location not in backup.locations: + if self.location not in backup.location_attributes: continue details = await self._client.backups.backup_info(backup.slug) result.append(_backup_details_to_agent_backup(details, self.location)) @@ -222,7 +222,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) except SupervisorNotFoundError: return None - if self.location not in details.locations: + if self.location not in details.location_attributes: return None return _backup_details_to_agent_backup(details, self.location) @@ -295,8 +295,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): # will be handled by async_upload_backup. # If the lists are the same length, it does not matter which one we send, # we send the encrypted list to have a well defined behavior. - encrypted_locations: list[str | None] = [] - decrypted_locations: list[str | None] = [] + encrypted_locations: list[str] = [] + decrypted_locations: list[str] = [] agents_settings = manager.config.data.agents for hassio_agent in hassio_agents: if password is not None: @@ -353,12 +353,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): eager_start=False, # To ensure the task is not started before we return ) - return (NewBackup(backup_job_id=backup.job_id), backup_task) + return (NewBackup(backup_job_id=backup.job_id.hex), backup_task) async def _async_wait_for_backup( self, backup: supervisor_backups.NewBackup, - locations: list[str | None], + locations: list[str], *, on_progress: Callable[[CreateBackupEvent], None], remove_after_upload: bool, @@ -508,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): else None ) - restore_location: str | None + restore_location: str if manager.backup_agents[agent_id].domain != DOMAIN: # Download the backup to the supervisor. Supervisor will clean up the backup # two days after the restore is done. @@ -577,10 +577,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" - if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)): _LOGGER.debug("No restore job ID found in environment") return + restore_job_id = UUID(restore_job_str) _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) sent_event = False @@ -634,7 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def _async_listen_job_events( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> Callable[[], None]: """Listen for job events.""" @@ -649,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): if ( data.get("event") != "job" or not (event_data := data.get("data")) - or event_data.get("uuid") != job_id + or event_data.get("uuid") != job_id.hex ): return on_event(event_data) @@ -660,10 +661,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return unsub async def _get_job_state( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> None: """Poll a job for its state.""" - job = await self._client.jobs.get_job(UUID(job_id)) + job = await self._client.jobs.get_job(job_id) _LOGGER.debug("Job state: %s", job) on_event(job.to_dict()) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ccc0f23fb43..ad98beb5baa 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b6"], + "requirements": ["aiohasupervisor==0.3.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5e22abb831b..a08b71e80bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.3 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 diff --git a/pyproject.toml b/pyproject.toml index 740d7d7bc3e..7790624269a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b6", + "aiohasupervisor==0.3.0", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", diff --git a/requirements.txt b/requirements.txt index a58065a3a7a..0f5ac0ba7d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 036f76aa876..c79123cd3da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7a34a729fa..7690b454fa6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 89a789cdc50..cf03ac35f52 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -26,6 +26,7 @@ from aiohasupervisor.models import ( jobs as supervisor_jobs, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,11 +40,7 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import ( - LOCATION_CLOUD_BACKUP, - LOCATION_LOCAL, - RESTORE_JOB_ID_ENV, -) +from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -60,17 +57,12 @@ TEST_BACKUP = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -89,14 +81,9 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP.location, location_attributes=TEST_BACKUP.location_attributes, - locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, slug=TEST_BACKUP.slug, supervisor_version="2024.11.2", type=TEST_BACKUP.type, @@ -110,17 +97,12 @@ TEST_BACKUP_2 = supervisor_backups.Backup( homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -139,14 +121,9 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_2.location, location_attributes=TEST_BACKUP_2.location_attributes, - locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, - protected=TEST_BACKUP_2.protected, repositories=[], - size=TEST_BACKUP_2.size, - size_bytes=TEST_BACKUP_2.size_bytes, slug=TEST_BACKUP_2.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_2.type, @@ -160,17 +137,12 @@ TEST_BACKUP_3 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location="share", location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={"share"}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -189,14 +161,9 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_3.location, location_attributes=TEST_BACKUP_3.location_attributes, - locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, - protected=TEST_BACKUP_3.protected, repositories=[], - size=TEST_BACKUP_3.size, - size_bytes=TEST_BACKUP_3.size_bytes, slug=TEST_BACKUP_3.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_3.type, @@ -211,17 +178,12 @@ TEST_BACKUP_4 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -240,14 +202,9 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP_4.location, location_attributes=TEST_BACKUP_4.location_attributes, - locations=TEST_BACKUP_4.locations, name=TEST_BACKUP_4.name, - protected=TEST_BACKUP_4.protected, repositories=[], - size=TEST_BACKUP_4.size, - size_bytes=TEST_BACKUP_4.size_bytes, slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_4.type, @@ -261,17 +218,12 @@ TEST_BACKUP_5 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=LOCATION_CLOUD_BACKUP, location_attributes={ LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={LOCATION_CLOUD_BACKUP}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -290,14 +242,9 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP_5.location, location_attributes=TEST_BACKUP_5.location_attributes, - locations=TEST_BACKUP_5.locations, name=TEST_BACKUP_5.name, - protected=TEST_BACKUP_5.protected, repositories=[], - size=TEST_BACKUP_5.size, - size_bytes=TEST_BACKUP_5.size_bytes, slug=TEST_BACKUP_5.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_5.type, @@ -312,6 +259,7 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=False, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_JOB_DONE = supervisor_jobs.Job( @@ -322,6 +270,7 @@ TEST_JOB_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=True, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( @@ -340,6 +289,7 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( ), ) ], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) @@ -580,7 +530,10 @@ async def test_agent_download( assert await resp.content.read() == b"backup data" supervisor_client.backups.download_backup.assert_called_once_with( - "abc123", options=supervisor_backups.DownloadBackupOptions(location=None) + "abc123", + options=supervisor_backups.DownloadBackupOptions( + location=LOCATION_LOCAL_STORAGE + ), ) @@ -766,7 +719,10 @@ async def test_agent_delete_backup( assert response["success"] assert response["result"] == {"agent_errors": {}} supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -812,7 +768,10 @@ async def test_agent_delete_with_error( assert response == {"id": 1, "type": "result"} | expected_response supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -891,7 +850,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, - location=[None], + location=[LOCATION_LOCAL_STORAGE], name="Test", password=None, ) @@ -947,7 +906,7 @@ async def test_reader_writer_create( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1022,7 +981,7 @@ async def test_reader_writer_create_report_progress( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1129,7 +1088,7 @@ async def test_reader_writer_create_job_done( """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1198,7 +1157,7 @@ async def test_reader_writer_create_job_done( None, ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], False, [], ), @@ -1207,7 +1166,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], "hunter2", - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], True, [], ), @@ -1225,7 +1184,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share1", "share2", "share3"], True, - [None], + [LOCATION_LOCAL_STORAGE], ), ( [ @@ -1242,7 +1201,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share2", "share3"], True, - [None, "share1"], + [LOCATION_LOCAL_STORAGE, "share1"], ), ( [ @@ -1258,7 +1217,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2"], + [LOCATION_LOCAL_STORAGE, "share1", "share2"], True, ["share3"], ), @@ -1274,7 +1233,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local"], None, - [None], + [LOCATION_LOCAL_STORAGE], False, [], ), @@ -1312,15 +1271,14 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, extra=DEFAULT_BACKUP_OPTIONS.extra, - locations=create_locations, location_attributes={ - location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + location: supervisor_backups.BackupLocationAttributes( protected=create_protected, - size_bytes=TEST_BACKUP_DETAILS.size_bytes, + size_bytes=1048576, ) for location in create_locations }, @@ -1514,7 +1472,7 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1581,7 +1539,7 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) @@ -1668,7 +1626,7 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1745,7 +1703,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1848,7 +1806,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1975,7 +1933,7 @@ async def test_reader_writer_restore( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = get_job_result @@ -2006,7 +1964,7 @@ async def test_reader_writer_restore( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2040,7 +1998,7 @@ async def test_reader_writer_restore_report_progress( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2071,7 +2029,7 @@ async def test_reader_writer_restore_report_progress( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2193,7 +2151,7 @@ async def test_reader_writer_restore_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2221,7 +2179,7 @@ async def test_reader_writer_restore_late_error( ) -> None: """Test restoring a backup with error.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2250,7 +2208,7 @@ async def test_reader_writer_restore_late_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) From 72a3c5296c2875babecebe3e8f26c10dc2361291 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Feb 2025 17:07:19 +0100 Subject: [PATCH 171/178] Update frontend to 20250205.0 (#137441) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b584fe5e2f0..d27785dcea5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250204.0"] + "requirements": ["home-assistant-frontend==20250205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a08b71e80bf..7a7fde68200 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c79123cd3da..16eb25c9e9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7690b454fa6..abbc78bdd77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 04b0d587c5b11fb47f9890af18347e2f0f6f954d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 16:18:01 +0000 Subject: [PATCH 172/178] Bump version to 2025.2.0b11 --- 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 c35dbcaa378..f3b186fd24f 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 = "0b10" +PATCH_VERSION: Final = "0b11" __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 7790624269a..8e4c355a479 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b10" +version = "2025.2.0b11" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d5ad91fce3a5c99728eee2bcf74df90b2aa0c502 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:03:54 +0100 Subject: [PATCH 173/178] Update bluetooth dependencies (#137353) --- homeassistant/components/bluetooth/manifest.json | 6 +++--- homeassistant/package_constraints.txt | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 22db886ef3f..32577b1bd7f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,11 +16,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.8.0", - "bluetooth-adapters==0.21.1", + "bleak-retry-connector==3.8.1", + "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.3", "dbus-fast==2.32.0", - "habluetooth==3.21.0" + "habluetooth==3.21.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a7fde68200..0bd196c2350 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,9 +19,9 @@ audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 bleak==0.22.3 -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.23.3 cached-ipaddress==0.8.0 @@ -33,7 +33,7 @@ dbus-fast==2.32.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.21.0 +habluetooth==3.21.1 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 16eb25c9e9f..c9c2b55d591 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ bizkaibus==0.1.1 bleak-esphome==2.7.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -622,7 +622,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.21.0 +habluetooth==3.21.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abbc78bdd77..d81fefb6f13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.7.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -546,7 +546,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.21.0 +habluetooth==3.21.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 95410586b1afa267a6f4a0e8e354702a1a685904 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:02:17 +0100 Subject: [PATCH 174/178] Update bluetooth-data-tools to 1.23.4 (#137374) Co-authored-by: J. Nick Koston --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 32577b1bd7f..a0405eb5ef5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.8.1", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.23.3", + "bluetooth-data-tools==1.23.4", "dbus-fast==2.32.0", "habluetooth==3.21.1" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index a29a9834c9b..36d0150642e 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.3", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ff620da1993..309399e6958 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 90518c81483..445affbcd57 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.3"] + "requirements": ["bluetooth-data-tools==1.23.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0bd196c2350..7022cc20d29 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.8.1 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index c9c2b55d591..43afb90550a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -631,7 +631,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d81fefb6f13..845e76ce572 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -555,7 +555,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 From d5dd0f6ec1f7442184782c2fd6b2a7119b8656d5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Feb 2025 11:02:01 -0600 Subject: [PATCH 175/178] Bump hassil and intents (#137440) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0485cb75fcb..2d4a8053d75 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7022cc20d29..445e729dea6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,10 +35,10 @@ go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 hass-nabucasa==0.88.1 -hassil==2.2.0 +hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250205.0 -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 43afb90550a..2b4f2050856 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ hass-nabucasa==0.88.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1146,7 +1146,7 @@ holidays==0.65 home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 845e76ce572..fee045f942d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habluetooth==3.21.1 hass-nabucasa==0.88.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -975,7 +975,7 @@ holidays==0.65 home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 2c433ba362e..6c865612f1a 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 65fde6042f4c70bda96e9cbc75226512d794782f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 11:17:02 -0600 Subject: [PATCH 176/178] Bump dbus-fast to 2.33.0 (#137446) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.32.0...v2.33.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a0405eb5ef5..5d2b8ab6285 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.4", - "dbus-fast==2.32.0", + "dbus-fast==2.33.0", "habluetooth==3.21.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 445e729dea6..2c3513589b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.32.0 +dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2b4f2050856..b1028c3efad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fee045f942d..03779787e33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -631,7 +631,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 From 5c7cabed1eb1d5e227a3f1bcd76a15e5541b15ad Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 17:30:55 +0000 Subject: [PATCH 177/178] Bump version to 2025.2.0b12 --- 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 f3b186fd24f..07019df72ed 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 = "0b11" +PATCH_VERSION: Final = "0b12" __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 8e4c355a479..cab55773567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b11" +version = "2025.2.0b12" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3a88c9d6f45b8655c5422734acada258387fe094 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 17:35:07 +0000 Subject: [PATCH 178/178] Bump version to 2025.2.0 --- 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 07019df72ed..111595ea83f 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 = "0b12" +PATCH_VERSION: Final = "0" __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 cab55773567..d7c0761887f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b12" +version = "2025.2.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"