From 8749210d1b2b065e5da837971b26a08053f85e9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 001/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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/288] 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 e6e42af8b6dfe1d38c831d1e71465661f7b66204 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:50:49 +0100 Subject: [PATCH 154/288] Update ismartgate to 5.0.2 (#137394) --- homeassistant/components/gogogate2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index 40633537ddf..238c145302a 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "local_polling", "loggers": ["ismartgate"], - "requirements": ["ismartgate==5.0.1"] + "requirements": ["ismartgate==5.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b643ebc3a87..090f35c404d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ iperf3==0.1.11 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 522ceac309d..d77c752dccd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1052,7 +1052,7 @@ iottycloud==0.3.0 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 From e134c862cd1e1c7432fc517e8e4ed449c59aa1b0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:51:24 +0100 Subject: [PATCH 155/288] Update discovergy30303 to 0.3.3 (#137396) --- homeassistant/components/steamist/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index f79c2aaa99c..ab81c8b5a53 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -16,5 +16,5 @@ "documentation": "https://www.home-assistant.io/integrations/steamist", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], - "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.2"] + "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 090f35c404d..ae27e0cca9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -785,7 +785,7 @@ directv==0.4.0 discogs-client==2.3.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d77c752dccd..29ff9381e9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -672,7 +672,7 @@ dio-chacon-wifi-api==1.2.1 directv==0.4.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 From b75a9d15d0af2a807be45a50a000bff999e0544a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 09:05:50 +0100 Subject: [PATCH 156/288] Fix spelling of "YoLink" and improve action descriptions (#137412) Make one occurrence of "yolink" consistent, using "YoLink" instead. Reword the descriptions of the play_on_speaker_hub action to improve translations by using more descriptive language. --- homeassistant/components/yolink/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index cbb092405d7..8ec7612fd73 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -6,7 +6,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The yolink integration needs to re-authenticate your account" + "description": "The YoLink integration needs to re-authenticate your account" } }, "abort": { @@ -99,11 +99,11 @@ "services": { "play_on_speaker_hub": { "name": "Play on SpeakerHub", - "description": "Convert text to audio play on YoLink SpeakerHub", + "description": "Converts text to speech for playback on a YoLink SpeakerHub", "fields": { "target_device": { - "name": "SpeakerHub Device", - "description": "SpeakerHub Device" + "name": "SpeakerHub device", + "description": "SpeakerHub device for audio playback." }, "message": { "name": "Text message", @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Override the speaker volume during playback of this message only." + "description": "Overrides the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", From 8349ea812512502306738c72ad4823d5d7f49720 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:09:33 +1000 Subject: [PATCH 157/288] 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 749bd7c4173..bfa0d831a16 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.10"] + "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"] } 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 ae27e0cca9f..b0069582032 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2857,7 +2857,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 29ff9381e9e..f43382956ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2297,7 +2297,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 5ef3cad89ad84835bb93654a7941c7126fbb7597 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 5 Feb 2025 19:14:32 +1100 Subject: [PATCH 158/288] Bump pysmlight to v0.2.3 (#137386) --- 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 cec5d6a6d8b..3f527d1fcd9 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.2.2"], + "requirements": ["pysmlight==0.2.3"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index b0069582032..69d42585a52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2313,7 +2313,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.2 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f43382956ca..8086bdece39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1885,7 +1885,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.2 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 From 03de3aec1584ae3512f89c0a5fee96893865ee4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Feb 2025 09:21:52 +0100 Subject: [PATCH 159/288] Bump sigstore/cosign-installer from 3.7.0 to 3.8.0 (#137404) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.7.0 to 3.8.0. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/v3.7.0...v3.8.0) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index aa4bfc60c11..cdffcbe4d5b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.7.0 + uses: sigstore/cosign-installer@v3.8.0 with: cosign-release: "v2.2.3" From 3fc13db7e08c6949951d18fc0b83f236ce3d34f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 02:29:23 -0600 Subject: [PATCH 160/288] 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 7ad0438a4e7cd1bb83e8669e67a54683ab03e5c9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:43:22 +1000 Subject: [PATCH 161/288] 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 3dc075f2877340f89d3e7dbe2315630f27f9302a Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 5 Feb 2025 19:43:38 +1100 Subject: [PATCH 162/288] 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 bfbf95f51517157e441eb669f18616a6f6efcee0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 10:14:39 +0100 Subject: [PATCH 163/288] 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 4d2c46959e4c7042ce817ee48ca09c9901d3b556 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:15:25 +0100 Subject: [PATCH 164/288] Add coordinator unsubscribe listener test (#137422) --- tests/helpers/test_update_coordinator.py | 41 +++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 539762a60ff..3ad5754dada 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import logging from unittest.mock import AsyncMock, Mock, patch import urllib.error +import weakref import aiohttp from freezegun.api import FrozenDateTimeFactory @@ -12,7 +13,7 @@ import requests from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -898,3 +899,41 @@ async def test_config_entry(hass: HomeAssistant) -> None: hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + + +async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: + """Test listener subscribe/unsubscribe releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + And https://github.com/home-assistant/core/pull/137338 + """ + + class Subscriber: + _unsub: CALLBACK_TYPE | None = None + + def start_listen( + self, coordinator: update_coordinator.DataUpdateCoordinator + ) -> None: + self._unsub = coordinator.async_add_listener(lambda: None) + + def stop_listen(self) -> None: + self._unsub() + self._unsub = None + + coordinator = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test" + ) + subscriber = Subscriber() + subscriber.start_listen(coordinator) + + # Keep weak reference to the coordinator + weak_ref = weakref.ref(coordinator) + assert weak_ref() is not None + + # Unload the subscriber, then shutdown the coordinator + subscriber.stop_listen() + await coordinator.async_shutdown() + del coordinator + + # Ensure the coordinator is released + assert weak_ref() is None From ac42c9386cb7e1a28695303f874280e8473d38a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 05:42:41 -0500 Subject: [PATCH 165/288] 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 b7c4951d8de..2ef785e7f71 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -329,7 +329,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." @@ -392,11 +392,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 @@ -428,8 +428,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 @@ -441,25 +442,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) @@ -480,12 +485,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 @@ -532,9 +538,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 @@ -816,15 +826,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 27b96160e2706b45e085f487ab059143cddcc973 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:02:58 +0100 Subject: [PATCH 166/288] 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 aa7d7ebd95c..608b54eefcc 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -426,7 +426,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, @@ -446,7 +446,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, @@ -456,7 +456,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, @@ -476,7 +476,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, @@ -487,7 +487,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, @@ -1676,7 +1676,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, @@ -1685,7 +1685,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 496dc93df32..6b6163acb15 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 28cedc4c13bb1421bd4867d7136e7be72d9c32f9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 12:13:58 +0100 Subject: [PATCH 167/288] Improve action descriptions in rainmachine, fix casing (#137428) --- homeassistant/components/rainmachine/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index a564d33e777..aad61458e88 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -5,7 +5,7 @@ "user": { "title": "Fill in your information", "data": { - "ip_address": "Hostname or IP Address", + "ip_address": "Hostname or IP address", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } @@ -157,7 +157,7 @@ }, "unpause_watering": { "name": "Unpause all watering", - "description": "Unpauses all paused watering activities.", + "description": "Resumes all paused watering activities.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -167,7 +167,7 @@ }, "push_flow_meter_data": { "name": "Push flow meter data", - "description": "Push flow meter data to the RainMachine device.", + "description": "Sends flow meter data from Home Assistant to the RainMachine device.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -185,7 +185,7 @@ }, "push_weather_data": { "name": "Push weather data", - "description": "Push weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", + "description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -193,7 +193,7 @@ }, "timestamp": { "name": "Timestamp", - "description": "UNIX Timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." + "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { "name": "Min temp", @@ -251,7 +251,7 @@ }, "unrestrict_watering": { "name": "Unrestrict all watering", - "description": "Unrestrict all watering activities.", + "description": "Removes all watering restrictions.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", From 2f116eab9ee404a9ded161248e7aa4469e03c42b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:14:06 +0100 Subject: [PATCH 168/288] 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 608b54eefcc..bdcb9f068b6 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 a1655d28ba3819551b90c17a736aee662f530dda Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 12:14:40 +0100 Subject: [PATCH 169/288] Fix sentence-casing and action names/descriptions in unifiprotect (#137418) --- .../components/unifiprotect/strings.json | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index cde8c88d169..d5a7d615399 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -3,8 +3,8 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "UniFi Protect Setup", - "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect setup", + "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -17,17 +17,17 @@ } }, "reauth_confirm": { - "title": "UniFi Protect Reauth", + "title": "UniFi Protect reauth", "data": { - "host": "IP/Host of UniFi Protect Server", + "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } }, "discovery_confirm": { - "title": "UniFi Protect Discovered", - "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect discovered", + "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -38,7 +38,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", - "cloud_user": "Ubiquiti Cloud users are not Supported. Please use a Local only user." + "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -49,12 +49,12 @@ "options": { "step": { "init": { - "title": "UniFi Protect Options", + "title": "UniFi Protect options", "description": "Realtime metrics option should only be enabled if you have enabled the diagnostics sensors and want them updated in realtime. If not enabled, they will only update once every 15 minutes.", "data": { "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", - "override_connection_host": "Override Connection Host", + "override_connection_host": "Override connection host", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" } @@ -68,7 +68,7 @@ "step": { "start": { "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." + "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." }, "confirm": { "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", @@ -123,8 +123,8 @@ } }, "deprecate_hdr_switch": { - "title": "HDR Mode Switch Deprecated", - "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode Switch has been replaced with an HDR Mode Select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." + "title": "HDR Mode switch deprecated", + "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode switch has been replaced with an HDR Mode select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." } }, "entity": { @@ -171,22 +171,22 @@ }, "services": { "add_doorbell_text": { - "name": "Add custom doorbell text", + "name": "Add doorbell text", "description": "Adds a new custom message for doorbells.", "fields": { "device_id": { "name": "UniFi Protect NVR", - "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances." + "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect instances." }, "message": { "name": "Custom message", - "description": "New custom message to add for doorbells. Must be less than 30 characters." + "description": "New custom message to add. Must be less than 30 characters." } } }, "remove_doorbell_text": { - "name": "Remove custom doorbell text", - "description": "Removes an existing message for doorbells.", + "name": "Remove doorbell text", + "description": "Removes an existing custom message for doorbells.", "fields": { "device_id": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", @@ -194,13 +194,13 @@ }, "message": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::message::name%]", - "description": "Existing custom message to remove for doorbells." + "description": "Existing custom message to remove." } } }, "set_chime_paired_doorbells": { "name": "Set chime paired doorbells", - "description": "Use to set the paired doorbell(s) with a smart chime.", + "description": "Pairs doorbell(s) with a smart chime.", "fields": { "device_id": { "name": "Chime", @@ -213,22 +213,22 @@ } }, "remove_privacy_zone": { - "name": "Remove camera privacy zone", - "description": "Use to remove a privacy zone from a camera.", + "name": "Remove privacy zone", + "description": "Removes a privacy zone from a camera.", "fields": { "device_id": { "name": "Camera", - "description": "Camera you want to remove privacy zone from." + "description": "Camera you want to remove the privacy zone from." }, "name": { - "name": "Privacy Zone Name", + "name": "Privacy zone", "description": "The name of the zone to remove." } } }, "get_user_keyring_info": { - "name": "Retrieve Keyring Details for Users", - "description": "Fetch a detailed list of users with NFC and fingerprint associations for automations.", + "name": "Get user keyring info", + "description": "Fetches a detailed list of users with NFC and fingerprint associations for automations.", "fields": { "device_id": { "name": "UniFi Protect NVR", From 1445e175219ce22f5b0d93c3bbff6e1f44c5e8b4 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Feb 2025 20:18:14 +0900 Subject: [PATCH 170/288] Bump thinqconnect to 1.0.4 (#137395) Co-authored-by: yunseon.park --- .../components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/air_conditioner/profile.json | 74 +++++++++++++++++++ .../fixtures/air_conditioner/status.json | 17 +++++ .../lg_thinq/snapshots/test_climate.ambr | 6 +- .../lg_thinq/snapshots/test_sensor.ambr | 47 ++++++++++++ 7 files changed, 145 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 6dd60909c66..b00d28c1d4f 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.2"] + "requirements": ["thinqconnect==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69d42585a52..e00a1bb8e4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2884,7 +2884,7 @@ thermopro-ble==0.11.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8086bdece39..a4fd83431b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2318,7 +2318,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.11.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json index 0d45dc5c9f4..85ce95da0ed 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -57,6 +57,16 @@ "type": "number" } }, + "filterInfo": { + "filterLifetime": { + "mode": ["r"], + "type": "number" + }, + "usedTime": { + "mode": ["r"], + "type": "number" + } + }, "operation": { "airCleanOperationMode": { "mode": ["w"], @@ -124,6 +134,52 @@ } } }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ], "timer": { "relativeHourToStart": { "mode": ["r", "w"], @@ -149,6 +205,24 @@ "mode": ["r", "w"], "type": "number" } + }, + "windDirection": { + "rotateUpDown": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + }, + "rotateLeftRight": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + } } } } diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json index 90d15d1ae16..8440e7da28c 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/status.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -32,6 +32,19 @@ "targetTemperature": 19, "unit": "C" }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ], + "timer": { "relativeStartTimer": "UNSET", "relativeStopTimer": "UNSET", @@ -39,5 +52,9 @@ "absoluteStopTimer": "UNSET", "absoluteHourToStart": 13, "absoluteMinuteToStart": 14 + }, + "windDirection": { + "rotateUpDown": false, + "rotateLeftRight": false } } diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e9470c3de03..9369367a1f7 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -43,7 +43,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -72,7 +72,9 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, 'target_temp_step': 1, 'temperature': 19, }), diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 2c58b109e61..fe1929944f9 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-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.test_air_conditioner_filter_remaining', + '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': 'Filter remaining', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_filter_lifetime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Filter remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '540', + }) +# --- # name: test_all_entities[sensor.test_air_conditioner_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d8179dacc66f2805be869413cb8bc7d9224e77b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 13:55:36 +0100 Subject: [PATCH 171/288] 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 172/288] 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 173/288] 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 174/288] 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 175/288] 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 176/288] 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 83edee47ff44e93200d65f645a21370b2e24bd0c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 13:09:22 +0100 Subject: [PATCH 177/288] Replace wrong name reference of binary jvc_power sensor in jvc_projector (#137271) --- homeassistant/components/jvc_projector/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index b517bf064e1..c6e5736bd2d 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -36,7 +36,7 @@ "entity": { "binary_sensor": { "jvc_power": { - "name": "[%key:component::sensor::entity_component::power::name%]" + "name": "[%key:component::binary_sensor::entity_component::power::name%]" } }, "select": { From 41490dffad0d7bbaeca2b5e445147e3a1aa225b1 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:09:59 -0500 Subject: [PATCH 178/288] Bump lacrosse-view to 1.1.1 (#137282) --- homeassistant/components/lacrosse_view/manifest.json | 2 +- homeassistant/components/lacrosse_view/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 86b2f61a872..38e64274deb 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.4"] + "requirements": ["lacrosse-view==1.1.1"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index b2ad9672504..fceddeb9b2c 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -45,7 +45,7 @@ class LaCrosseSensorEntityDescription(SensorEntityDescription): def get_value(sensor: Sensor, field: str) -> float | int | str | None: """Get the value of a sensor field.""" - field_data = sensor.data.get(field) + field_data = sensor.data.get(field) if sensor.data is not None else None if field_data is None: return None value = field_data["values"][-1]["s"] @@ -178,7 +178,7 @@ async def async_setup_entry( continue # if the API returns a different unit of measurement from the description, update it - if sensor.data.get(field) is not None: + if sensor.data is not None and sensor.data.get(field) is not None: native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( sensor.data[field].get("unit") ) @@ -240,7 +240,9 @@ class LaCrosseViewSensor( @property def available(self) -> bool: """Return True if entity is available.""" + data = self.coordinator.data[self.index].data return ( super().available - and self.entity_description.key in self.coordinator.data[self.index].data + and data is not None + and self.entity_description.key in data ) diff --git a/requirements_all.txt b/requirements_all.txt index e00a1bb8e4f..a5217c42236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1284,7 +1284,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.eufy lakeside==0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4fd83431b1..e3b233ba8e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1086,7 +1086,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.laundrify laundrify-aio==1.2.2 From 0764c7e7739cb7c5707a3432f9daba28c7875bfc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:09:33 +1000 Subject: [PATCH 179/288] 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 180/288] 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 181/288] 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 182/288] 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 183/288] 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 184/288] 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 185/288] 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 fcb8d25b461db55fa26a0d90ce9b9ceeb886731e Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:40:33 +0100 Subject: [PATCH 186/288] Show new errors from the MotionMount (#137006) --- .../components/motionmount/sensor.py | 31 ++++++++---- .../components/motionmount/strings.json | 3 ++ tests/components/motionmount/test_sensor.py | 48 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 tests/components/motionmount/test_sensor.py diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 8e55fad4a8b..685c3ebf932 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -1,6 +1,9 @@ """Support for MotionMount sensors.""" +from typing import Final + import motionmount +from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant @@ -9,6 +12,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity +ERROR_MESSAGES: Final = { + MotionMountSystemError.MotorError: "motor", + MotionMountSystemError.ObstructionDetected: "obstruction", + MotionMountSystemError.TVWidthConstraintError: "tv_width_constraint", + MotionMountSystemError.HDMICECError: "hdmi_cec", + MotionMountSystemError.InternalError: "internal", +} + async def async_setup_entry( hass: HomeAssistant, @@ -25,7 +36,14 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): """The error status sensor of a MotionMount.""" _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["none", "motor", "internal"] + _attr_options = [ + "none", + "motor", + "hdmi_cec", + "obstruction", + "tv_width_constraint", + "internal", + ] _attr_translation_key = "motionmount_error_status" def __init__( @@ -38,13 +56,10 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): @property def native_value(self) -> str: """Return error status.""" - errors = self.mm.error_status or 0 + status = self.mm.system_status - if errors & (1 << 31): - # Only when but 31 is set are there any errors active at this moment - if errors & (1 << 10): - return "motor" - - return "internal" + for error, message in ERROR_MESSAGES.items(): + if error in status: + return message return "none" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 1fcb6c47c99..75fd0773322 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -72,6 +72,9 @@ "state": { "none": "None", "motor": "Motor", + "hdmi_cec": "HDMI CEC", + "obstruction": "Obstruction", + "tv_width_constraint": "TV width constraint", "internal": "Internal" } } diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py new file mode 100644 index 00000000000..bb68c67ce62 --- /dev/null +++ b/tests/components/motionmount/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the MotionMount Sensor platform.""" + +from unittest.mock import patch + +from motionmount import MotionMountSystemError +import pytest + +from homeassistant.core import HomeAssistant + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") + + +@pytest.mark.parametrize( + ("system_status", "state"), + [ + (None, "none"), + (MotionMountSystemError.MotorError, "motor"), + (MotionMountSystemError.ObstructionDetected, "obstruction"), + (MotionMountSystemError.TVWidthConstraintError, "tv_width_constraint"), + (MotionMountSystemError.HDMICECError, "hdmi_cec"), + (MotionMountSystemError.InternalError, "internal"), + ], +) +async def test_error_status_sensor_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + system_status: MotionMountSystemError, + state: str, +) -> None: + """Tests the state attributes.""" + with patch( + "homeassistant.components.motionmount.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + motionmount_mock.return_value.name = ZEROCONF_NAME + motionmount_mock.return_value.mac = MAC + motionmount_mock.return_value.is_authenticated = True + motionmount_mock.return_value.system_status = [system_status] + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert hass.states.get("sensor.my_motionmount_error_status").state == state From 0236f2434e190828038fd23896b29baf5480b05b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 5 Feb 2025 13:48:12 +0100 Subject: [PATCH 187/288] 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 a5217c42236..1254c7521f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2606,7 +2606,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 e3b233ba8e4..758365eeeb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2109,7 +2109,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 eded99a0591c6627719f05ab0a5844cda4701cd4 Mon Sep 17 00:00:00 2001 From: Mick Montorier-Aberman Date: Wed, 5 Feb 2025 13:51:27 +0100 Subject: [PATCH 188/288] Add tests for sensors in SwitchBot Cloud (#137319) --- tests/components/switchbot_cloud/__init__.py | 4 +- .../fixtures/meter_status.json | 9 + .../snapshots/test_sensor.ambr | 307 ++++++++++++++++++ .../components/switchbot_cloud/test_button.py | 10 +- tests/components/switchbot_cloud/test_init.py | 10 +- tests/components/switchbot_cloud/test_lock.py | 4 +- .../components/switchbot_cloud/test_sensor.py | 65 ++++ .../components/switchbot_cloud/test_switch.py | 15 +- 8 files changed, 393 insertions(+), 31 deletions(-) create mode 100644 tests/components/switchbot_cloud/fixtures/meter_status.json create mode 100644 tests/components/switchbot_cloud/snapshots/test_sensor.ambr create mode 100644 tests/components/switchbot_cloud/test_sensor.py diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index ce570499b3a..42fe3e4f543 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -def configure_integration(hass: HomeAssistant) -> MockConfigEntry: +async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: """Configure the integration.""" config = { CONF_API_TOKEN: "test-token", @@ -17,5 +17,7 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" ) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/switchbot_cloud/fixtures/meter_status.json b/tests/components/switchbot_cloud/fixtures/meter_status.json new file mode 100644 index 00000000000..8b5bcd0c031 --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/meter_status.json @@ -0,0 +1,9 @@ +{ + "version": "V3.3", + "temperature": 21.8, + "battery": 100, + "humidity": 32, + "deviceId": "meter-id-1", + "deviceType": "Meter", + "hubDeviceId": "test-hub-id" +} diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a9b6fb20bfb --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -0,0 +1,307 @@ +# serializer version: 1 +# name: test_meter[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_meter[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.8', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index df5b7569100..0779e54ee03 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -28,10 +28,7 @@ async def test_pressmode_bot( mock_get_status.return_value = {"deviceMode": "pressMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "button.bot_1" @@ -63,9 +60,6 @@ async def test_switchmode_bot_no_button_entity( mock_get_status.return_value = {"deviceMode": "switchMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(BUTTON_DOMAIN) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index d5728faf369..f4837c4e97e 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -64,9 +64,7 @@ async def test_setup_entry_success( ), ] mock_get_status.return_value = {"power": PowerState.ON.value} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -91,8 +89,7 @@ async def test_setup_entry_fails_when_listing_devices( ) -> None: """Test error handling when list_devices in setup of entry.""" mock_list_devices.side_effect = error - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state == state hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -114,8 +111,7 @@ async def test_setup_entry_fails_when_refreshing( ) ] mock_get_status.side_effect = CannotConnect - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index a09d7241794..fcb81abfc51 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -26,9 +26,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> mock_get_status.return_value = {"lockState": "locked"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py new file mode 100644 index 00000000000..6b0a52800f3 --- /dev/null +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -0,0 +1,65 @@ +"""Test for the switchbot_cloud sensors.""" + +from unittest.mock import patch + +from switchbot_api import Device +from syrupy import SnapshotAssertion + +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import configure_integration + +from tests.common import load_json_object_fixture, snapshot_platform + + +async def test_meter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test Meter sensors.""" + + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = load_json_object_fixture("meter_status.json", DOMAIN) + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_meter_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test meter sensors are unknown without coordinator data.""" + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index b1c6fb81b96..99e0f50aa53 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -34,10 +34,7 @@ async def test_relay_switch( mock_get_status.return_value = {"switchStatus": 0} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.relay_switch_1" @@ -71,10 +68,7 @@ async def test_switchmode_bot( mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.bot_1" @@ -108,9 +102,6 @@ async def test_pressmode_bot_no_switch_entity( mock_get_status.return_value = {"deviceMode": "pressMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(SWITCH_DOMAIN) From 417a595b73946634a73790a50b36bb9d37e357b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 08:25:05 -0500 Subject: [PATCH 189/288] Only clean up chat log if it was stored (#137399) --- .../components/conversation/chat_log.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 53e248d0a98..ad7a9d0ce9e 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -43,13 +43,6 @@ def async_get_chat_log( else: history = ChatLog(hass, session.conversation_id) - @callback - def do_cleanup() -> None: - """Handle cleanup.""" - all_history.pop(session.conversation_id) - - session.async_on_cleanup(do_cleanup) - if user_input is not None: history.async_add_user_content(UserContent(content=user_input.text)) @@ -63,6 +56,15 @@ def async_get_chat_log( ) return + if session.conversation_id not in all_history: + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_history.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + all_history[session.conversation_id] = history From c4411914c2e906105b765c00af5740bd0880e946 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 14:30:31 +0100 Subject: [PATCH 190/288] Replace "Ota" with "OTA update" in anova integration (#137431) --- homeassistant/components/anova/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index bfe3a61282e..e9905e4cce5 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -39,7 +39,7 @@ "idle": "[%key:common::state::idle%]", "cook": "Cooking", "low_water": "Low water", - "ota": "Ota", + "ota": "OTA update", "provisioning": "Provisioning", "high_temp": "High temperature", "device_failure": "Device failure" From 4d7bd1291d2fe7c85e243aa4d05e6620fe41ad4b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Feb 2025 15:26:58 +0100 Subject: [PATCH 191/288] 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 1254c7521f5..87cb85303ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,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 758365eeeb7..e7706013267 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1307,7 +1307,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 4613469eb7770f7e603460479d215f1c3272de07 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 5 Feb 2025 15:28:30 +0100 Subject: [PATCH 192/288] Fix spelling of "PowerView Hub" in user-facing strings (#137435) --- homeassistant/components/hunterdouglas_powerview/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index a107e2c5be4..231270d6eef 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -5,7 +5,7 @@ "title": "Connect to the PowerView Hub", "data": { "host": "[%key:common::config_flow::data::ip%]", - "api_version": "Hub Generation" + "api_version": "Hub generation" }, "data_description": { "api_version": "API version is detectable, but you can override and force a specific version" @@ -19,7 +19,7 @@ "flow_title": "{name} ({host})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unsupported_device": "Only the primary powerview hub can be added", + "unsupported_device": "Only the primary PowerView Hub can be added", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From cc59f5812b7a19a8581ffb00667e161ab6ba3f8a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:36:05 +0100 Subject: [PATCH 193/288] Disable annotations for pytest warnings [ci] (#137434) --- .github/workflows/ci.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 863c861db75..2a9f1571830 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -975,6 +975,7 @@ jobs: ${cov_params[@]} \ -o console_output_style=count \ -p no:sugar \ + --exclude-warning-annotations \ $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt) \ 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output @@ -1098,6 +1099,7 @@ jobs: -o console_output_style=count \ --durations=10 \ -p no:sugar \ + --exclude-warning-annotations \ --dburl=mysql://root:password@127.0.0.1/homeassistant-test \ tests/components/history \ tests/components/logbook \ @@ -1228,6 +1230,7 @@ jobs: --durations=0 \ --durations-min=10 \ -p no:sugar \ + --exclude-warning-annotations \ --dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \ tests/components/history \ tests/components/logbook \ @@ -1374,6 +1377,7 @@ jobs: --durations=0 \ --durations-min=1 \ -p no:sugar \ + --exclude-warning-annotations \ tests/components/${{ matrix.group }} \ 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output From 86a4f7188dd28223ba58fb76a16ecc9f1c5a2fe8 Mon Sep 17 00:00:00 2001 From: Arkadiusz Wahlig Date: Wed, 5 Feb 2025 16:32:05 +0100 Subject: [PATCH 194/288] Update PySwitchbot to 0.56.0 (#137432) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 1b80da43e16..92a1c25d6f5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.55.4"] + "requirements": ["PySwitchbot==0.56.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 87cb85303ce..1f0c440e76b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7706013267..ef4ac360d50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From d48d4284c50f46f6eca9a83ebd3124c0c7875c45 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 6 Feb 2025 01:48:50 +1000 Subject: [PATCH 195/288] Add streaming select entities to Teslemetry (#137210) --- homeassistant/components/teslemetry/select.py | 275 ++++++++++++------ .../teslemetry/snapshots/test_select.ambr | 75 +++++ tests/components/teslemetry/test_select.py | 47 ++- 3 files changed, 305 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index baf1d80ac6c..d2e90a4f5c9 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -2,18 +2,27 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain +from typing import Any +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -24,53 +33,136 @@ HIGH = "high" PARALLEL_UPDATES = 0 +LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} + @dataclass(frozen=True, kw_only=True) -class SeatHeaterDescription(SelectEntityDescription): +class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - position: Seat - available_fn: Callable[[TeslemetrySeatHeaterSelectEntity], bool] = lambda _: True + select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + supported_fn: Callable[[dict], bool] = lambda _: True + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + | None + ) = None + options: list[str] -SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( - SeatHeaterDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_left", - position=Seat.FRONT_LEFT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_LEFT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_right", - position=Seat.FRONT_RIGHT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_RIGHT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_left", - position=Seat.REAR_LEFT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_LEFT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearLeft(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_center", - position=Seat.REAR_CENTER, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_CENTER, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearCenter(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_right", - position=Seat.REAR_RIGHT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_RIGHT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearRight(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_left", - position=Seat.THIRD_LEFT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_LEFT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_right", - position=Seat.THIRD_RIGHT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_RIGHT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_steering_wheel_heat_level", + select_fn=lambda api, level: api.remote_steering_wheel_heat_level_request( + level + ), + streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatLevel(y), + options=[ + OFF, + LOW, + HIGH, + ], ), ) @@ -85,17 +177,18 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetrySeatHeaterSelectEntity( + TeslemetryPollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - for description in SEAT_HEATER_DESCRIPTIONS + if vehicle.api.pre2021 + or vehicle.firmware < "2024.26" + or description.streaming_listener is None + else TeslemetryStreamingSelectEntity( + vehicle, description, entry.runtime_data.scopes + ) + for description in VEHICLE_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles - if description.key in vehicle.coordinator.data - ), - ( - TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("climate_state_steering_wheel_heater") + if description.supported_fn(vehicle.coordinator.data) ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) @@ -112,22 +205,31 @@ async def async_setup_entry( ) -class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle seat heater.""" +class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): + """Parent vehicle select entity class.""" - entity_description: SeatHeaterDescription + entity_description: TeslemetrySelectEntityDescription + _climate: bool = False - _attr_options = [ - OFF, - LOW, - MEDIUM, - HIGH, - ] + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + level = LEVEL[option] + # AC must be on to turn on heaters + if level and not self._climate: + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command(self.entity_description.select_fn(self.api, level)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryPollingSelectEntity(TeslemetryVehicleEntity, TeslemetrySelectEntity): + """Base polling vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, - description: SeatHeaterDescription, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: """Initialize the vehicle seat select entity.""" @@ -137,72 +239,63 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" - self._attr_available = self.entity_description.available_fn(self) - value = self._value - if not isinstance(value, int): + self._climate = bool(self.get("climate_state_is_climate_on")) + if not isinstance(self._value, int): self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on seat heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_seat_heater_request(self.entity_description.position, level) - ) - self._attr_current_option = option - self.async_write_ha_state() + self._attr_current_option = self.entity_description.options[self._value] -class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle steering wheel heater.""" - - _attr_options = [ - OFF, - LOW, - HIGH, - ] +class TeslemetryStreamingSelectEntity( + TeslemetryVehicleStreamEntity, TeslemetrySelectEntity, RestoreEntity +): + """Base streaming vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: - """Initialize the vehicle steering wheel select entity.""" + """Initialize the vehicle seat select entity.""" + self.entity_description = description self.scoped = Scope.VEHICLE_CMDS in scopes - super().__init__( - data, - "climate_state_steering_wheel_heat_level", + self._attr_current_option = None + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state in self.entity_description.options: + self._attr_current_option = state.state + + # Listen for streaming data + assert self.entity_description.streaming_listener is not None + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) ) - def _async_update_attrs(self) -> None: - """Handle updated data from the coordinator.""" + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled(self._climate_callback) + ) - value = self._value - if not isinstance(value, int): + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + if value is None: self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on steering wheel heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_steering_wheel_heat_level_request(level) - ) - self._attr_current_option = option + self._attr_current_option = self.entity_description.options[value] self.async_write_ha_state() + def _climate_callback(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._climate = bool(value) + class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): """Select entity for operation mode select entities.""" diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 0c2547f309d..90af1259273 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -408,3 +408,78 @@ 'state': 'off', }) # --- +# name: test_select[select.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_steering_wheel_heater', + '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': 'Steering wheel heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heat_level', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_steering_wheel_heat_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Steering wheel heater', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_streaming[select.test_seat_heater_front_left] + 'off' +# --- +# name: test_select_streaming[select.test_seat_heater_front_right] + 'low' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_center] + 'unknown' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_left] + 'medium' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_right] + 'high' +# --- +# name: test_select_streaming[select.test_steering_wheel_heater] + 'off' +# --- diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index 005a6a2004e..c49e83803cd 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from teslemetry_stream.const import Signal from homeassistant.components.select import ( ATTR_OPTION, @@ -16,7 +17,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -25,6 +26,7 @@ async def test_select( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities are correct.""" @@ -106,6 +108,7 @@ async def test_select_invalid_data( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities handle invalid data.""" @@ -119,3 +122,45 @@ async def test_select_invalid_data( assert state.state == STATE_UNKNOWN state = hass.states.get("select.test_steering_wheel_heater") assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SELECT]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SEAT_HEATER_LEFT: 0, + Signal.SEAT_HEATER_RIGHT: 1, + Signal.SEAT_HEATER_REAR_LEFT: 2, + Signal.SEAT_HEATER_REAR_RIGHT: 3, + Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: 0, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.SELECT]) + + # Assert the entities restored their values + for entity_id in ( + "select.test_seat_heater_front_left", + "select.test_seat_heater_front_right", + "select.test_seat_heater_rear_left", + "select.test_seat_heater_rear_center", + "select.test_seat_heater_rear_right", + "select.test_steering_wheel_heater", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) From 9abea5c5bb419d1b5015957d4df568dd65138833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 5 Feb 2025 16:57:10 +0100 Subject: [PATCH 196/288] Add translation key for Matter Energy management mode (#137259) --- homeassistant/components/matter/select.py | 2 +- homeassistant/components/matter/strings.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index dd4f8314bef..b2d1c7f8ddb 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -308,7 +308,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterDeviceEnergyManagementMode", - translation_key="mode", + translation_key="device_energy_management_mode", ), entity_class=MatterModeSelectEntity, required_attributes=( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f1a123c61be..f299b5cb628 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -183,6 +183,9 @@ "mode": { "name": "Mode" }, + "device_energy_management_mode": { + "name": "Energy management mode" + }, "sensitivity_level": { "name": "Sensitivity", "state": { From 3f2e6d102cc0082fd979a40e2512dc7d5526ca57 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 16:58:39 +0100 Subject: [PATCH 197/288] 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 bf9b7262194..df9ba88f1a2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.2.2 aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.2 aiohttp==3.11.11 diff --git a/pyproject.toml b/pyproject.toml index f1baf85cdf3..8ddf46d8be9 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.2", diff --git a/requirements.txt b/requirements.txt index 1a80837e2cc..d8d7b235390 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.2 diff --git a/requirements_all.txt b/requirements_all.txt index 1f0c440e76b..d3de85fdf58 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.home_connect aiohomeconnect==0.12.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef4ac360d50..ba70aa2eec3 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.home_connect aiohomeconnect==0.12.3 diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6b6163acb15..0dd2adc99ed 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 078996effd63b4a51a89e8e00e96c4ea458397f4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Feb 2025 17:07:19 +0100 Subject: [PATCH 198/288] 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 df9ba88f1a2..ecfee7cee04 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.89.0 hassil==2.2.0 home-assistant-bluetooth==1.13.1 -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 d3de85fdf58..48fe3b24a16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,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 ba70aa2eec3..400a1522bb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,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 4694240cfa49dbe6b056ee84c71cbd978cd7e48d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 5 Feb 2025 09:07:35 -0700 Subject: [PATCH 199/288] Refactor switch for vesync (#134409) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/common.py | 14 +++ homeassistant/components/vesync/switch.py | 115 +++++++++++------- .../vesync/snapshots/test_switch.ambr | 6 +- 3 files changed, 86 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index e2f4e1db2e4..f817c1d0714 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -4,6 +4,8 @@ import logging from pyvesync import VeSync from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant @@ -54,3 +56,15 @@ def is_humidifier(device: VeSyncBaseDevice) -> bool: """Check if the device represents a humidifier.""" return isinstance(device, VeSyncHumidifierDevice) + + +def is_outlet(device: VeSyncBaseDevice) -> bool: + """Check if the device represents an outlet.""" + + return isinstance(device, VeSyncOutlet) + + +def is_wall_switch(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a wall switch, note this doessn't include dimming switches.""" + + return isinstance(device, VeSyncWallSwitch) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index efae1192406..3d2dc8a8e96 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -1,29 +1,59 @@ """Support for VeSync switches.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from typing import Any +from typing import Any, Final from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .common import is_outlet, is_wall_switch +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class VeSyncSwitchEntityDescription(SwitchEntityDescription): + """A class that describes custom switch entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + exists_fn: Callable[[VeSyncBaseDevice], bool] + on_fn: Callable[[VeSyncBaseDevice], bool] + off_fn: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( + VeSyncSwitchEntityDescription( + key="device_status", + is_on=lambda device: device.device_status == "on", + # Other types of wall switches support dimming. Those use light.py platform. + exists_fn=lambda device: is_wall_switch(device) or is_outlet(device), + name=None, + on_fn=lambda device: device.turn_on(), + off_fn=lambda device: device.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up switch platform.""" coordinator = hass.data[DOMAIN][VS_COORDINATOR] @@ -45,55 +75,46 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is a switch and add entity.""" - entities: list[VeSyncBaseSwitch] = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": - entities.append(VeSyncSwitchHA(dev, coordinator)) - elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": - entities.append(VeSyncLightSwitch(dev, coordinator)) - - async_add_entities(entities, update_before_add=True) + """Check if device is online and add entity.""" + async_add_entities( + VeSyncSwitchEntity(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if description.exists_fn(dev) + ) -class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity): - """Base class for VeSync switch Device Representations.""" +class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): + """VeSync switch entity class.""" - _attr_name = None + entity_description: VeSyncSwitchEntityDescription - def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - self.device.turn_on() + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSwitchEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + if is_outlet(self.device): + self._attr_device_class = SwitchDeviceClass.OUTLET + elif is_wall_switch(self.device): + self._attr_device_class = SwitchDeviceClass.SWITCH @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" + def is_on(self) -> bool | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.is_on(self.device) def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() + """Turn the entity off.""" + if self.entity_description.off_fn(self.device): + self.schedule_update_ha_state() - -class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): - """Representation of a VeSync switch.""" - - def __init__( - self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync switch device.""" - super().__init__(plug, coordinator) - self._attr_unique_id = f"{super().unique_id}-device_status" - self.smartplug = plug - - -class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): - """Handle representation of VeSync Light Switch.""" - - def __init__( - self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize Light Switch device class.""" - super().__init__(switch, coordinator) - self._attr_unique_id = f"{super().unique_id}-device_status" - self.switch = switch + def turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if self.entity_description.on_fn(self.device): + self.schedule_update_ha_state() diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index da652b30ac5..a736f1cd186 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -360,7 +360,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', @@ -375,6 +375,7 @@ # name: test_switch_state[Outlet][switch.outlet] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', 'friendly_name': 'Outlet', }), 'context': , @@ -518,7 +519,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', @@ -533,6 +534,7 @@ # name: test_switch_state[Wall Switch][switch.wall_switch] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'switch', 'friendly_name': 'Wall Switch', }), 'context': , From 5687a4d7185c20d23f3ed8767716318bdd37012d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Feb 2025 15:26:58 +0100 Subject: [PATCH 200/288] 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 201/288] 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 202/288] 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 203/288] 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 204/288] 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 830636df07e190a8d00162a9de0eacfe780dc68a Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:18:32 +0100 Subject: [PATCH 205/288] Govee light local bump (#137436) --- .../components/govee_light_local/light.py | 8 ++++---- .../components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/govee_light_local/conftest.py | 11 +++++------ .../govee_light_local/test_config_flow.py | 4 ++-- tests/components/govee_light_local/test_light.py | 16 ++++++++-------- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index cb2e24fa8a6..c7799a7ffc4 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from govee_local_api import GoveeDevice, GoveeLightCapability +from govee_local_api import GoveeDevice, GoveeLightFeatures from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -71,13 +71,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): capabilities = device.capabilities color_modes = {ColorMode.ONOFF} if capabilities: - if GoveeLightCapability.COLOR_RGB in capabilities: + if GoveeLightFeatures.COLOR_RGB & capabilities.features: color_modes.add(ColorMode.RGB) - if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: + if GoveeLightFeatures.COLOR_KELVIN_TEMPERATURE & capabilities.features: color_modes.add(ColorMode.COLOR_TEMP) self._attr_max_color_temp_kelvin = 9000 self._attr_min_color_temp_kelvin = 2000 - if GoveeLightCapability.BRIGHTNESS in capabilities: + if GoveeLightFeatures.BRIGHTNESS & capabilities.features: color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(color_modes) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index a94d4e58e9a..e813ab545df 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.5.3"] + "requirements": ["govee-local-api==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48fe3b24a16..608ba7b79f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1055,7 +1055,7 @@ gotailwind==0.3.0 govee-ble==0.42.1 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 400a1522bb8..a0bf2263de4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -905,7 +905,7 @@ gotailwind==0.3.0 govee-ble==0.42.1 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.0 # homeassistant.components.gpsd gps3==0.33.3 diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 6a8ee99b764..61a6394bd6a 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,7 +4,8 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapability +from govee_local_api import GoveeLightCapabilities +from govee_local_api.light_capabilities import COMMON_FEATURES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @@ -34,8 +35,6 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { - GoveeLightCapability.COLOR_RGB, - GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, - GoveeLightCapability.BRIGHTNESS, -} +DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES, segments=[], scenes={} +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 2e7144fae3a..103159f1a2b 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.govee_light_local.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: @@ -20,7 +20,7 @@ def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 4a1125643fa..24bdbba9e11 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES from tests.common import MockConfigEntry @@ -26,7 +26,7 @@ async def test_light_known_device( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -96,7 +96,7 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -152,7 +152,7 @@ async def test_light_setup_retry_eaddrinuse( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -180,7 +180,7 @@ async def test_light_setup_error( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -204,7 +204,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -260,7 +260,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -335,7 +335,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] From c9ab75a02d465f80fad6054b26d26244ef8d78dd Mon Sep 17 00:00:00 2001 From: Arkadiusz Wahlig Date: Wed, 5 Feb 2025 17:38:34 +0100 Subject: [PATCH 206/288] Add support for Switchbot Remote (#137443) --- .../components/switchbot/__init__.py | 1 + homeassistant/components/switchbot/const.py | 2 + tests/components/switchbot/__init__.py | 20 ++++++++++ tests/components/switchbot/test_sensor.py | 40 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 499a5073872..09bc157d4d2 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -65,6 +65,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], + SupportedModels.REMOTE.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 854ab32b657..16b41d75541 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -34,6 +34,7 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1PM = "relay_switch_1pm" RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" + REMOTE = "remote" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -60,6 +61,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, + SwitchbotModel.REMOTE: SupportedModels.REMOTE, } SUPPORTED_MODEL_TYPES = ( diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 9ecffd395a3..4d6794b962f 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -274,3 +274,23 @@ LEAK_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + +REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Any"), + time=0, + connectable=False, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index acf1bacc054..6a7111a054e 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.setup import async_setup_component from . import ( LEAK_SERVICE_INFO, + REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, @@ -194,3 +195,42 @@ async def test_leak_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remote(hass: HomeAssistant) -> None: + """Test setting up the remote sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, REMOTE_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "remote", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "86" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From a1c675b5eed1785442e7c09cff3b8f2d5fa1a3c8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Feb 2025 11:02:01 -0600 Subject: [PATCH 207/288] 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 ecfee7cee04..ad74191a781 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.89.0 -hassil==2.2.0 +hassil==2.2.3 home-assistant-bluetooth==1.13.1 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 608ba7b79f5..b8afcd96fe2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ hass-nabucasa==0.89.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1149,7 +1149,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.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0bf2263de4..c3d47788d00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ habluetooth==3.21.1 hass-nabucasa==0.89.0 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -978,7 +978,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.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 22eae847706..5598c839257 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.27,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.25.0 tqdm==4.67.1 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 164f43a71bdef4275812f78fd3a11730c58efadd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 11:17:02 -0600 Subject: [PATCH 208/288] 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 ad74191a781..46bdc2b9f68 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 b8afcd96fe2..f637bf8e5bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,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 c3d47788d00..f95171794aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -634,7 +634,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 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 209/288] 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 210/288] 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 09483d2cef3e90348cde39564a1263d7f86f5ec0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:27:13 +0100 Subject: [PATCH 211/288] Move fireservicerota coordinator to own module (#137304) --- .../components/fireservicerota/__init__.py | 193 +--------------- .../fireservicerota/binary_sensor.py | 15 +- .../components/fireservicerota/coordinator.py | 213 ++++++++++++++++++ .../fireservicerota/test_config_flow.py | 2 +- 4 files changed, 225 insertions(+), 198 deletions(-) create mode 100644 homeassistant/components/fireservicerota/coordinator.py diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index aa303a08795..360a0f0b210 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -3,29 +3,16 @@ from __future__ import annotations from datetime import timedelta -import logging - -from pyfireservicerota import ( - ExpiredTokenError, - FireServiceRota, - FireServiceRotaIncidents, - InvalidAuthError, - InvalidTokenError, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN, WSS_BWRURL +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] @@ -40,17 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if client.token_refresh_failure: return False - async def async_update_data(): - return await client.async_update() - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="duty binary sensor", - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) + coordinator = FireServiceUpdateCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -74,165 +51,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class FireServiceRotaOauth: - """Handle authentication tokens.""" - - def __init__(self, hass, entry, fsr): - """Initialize the oauth object.""" - self._hass = hass - self._entry = entry - - self._url = entry.data[CONF_URL] - self._username = entry.data[CONF_USERNAME] - self._fsr = fsr - - async def async_refresh_tokens(self) -> bool: - """Refresh tokens and update config entry.""" - _LOGGER.debug("Refreshing authentication tokens after expiration") - - try: - token_info = await self._hass.async_add_executor_job( - self._fsr.refresh_tokens - ) - - except (InvalidAuthError, InvalidTokenError) as err: - raise ConfigEntryAuthFailed( - "Error refreshing tokens, triggered reauth workflow" - ) from err - - _LOGGER.debug("Saving new tokens in config entry") - self._hass.config_entries.async_update_entry( - self._entry, - data={ - "auth_implementation": DOMAIN, - CONF_URL: self._url, - CONF_USERNAME: self._username, - CONF_TOKEN: token_info, - }, - ) - - return True - - -class FireServiceRotaWebSocket: - """Define a FireServiceRota websocket manager object.""" - - def __init__(self, hass, entry): - """Initialize the websocket object.""" - self._hass = hass - self._entry = entry - - self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) - self.incident_data = None - - def _construct_url(self) -> str: - """Return URL with latest access token.""" - return WSS_BWRURL.format( - self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] - ) - - def _on_incident(self, data) -> None: - """Received new incident, update data.""" - _LOGGER.debug("Received new incident via websocket: %s", data) - self.incident_data = data - dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") - - def start_listener(self) -> None: - """Start the websocket listener.""" - _LOGGER.debug("Starting incidents listener") - self._fsr_incidents.start(self._construct_url()) - - def stop_listener(self) -> None: - """Stop the websocket listener.""" - _LOGGER.debug("Stopping incidents listener") - self._fsr_incidents.stop() - - -class FireServiceRotaClient: - """Getting the latest data from fireservicerota.""" - - def __init__(self, hass, entry): - """Initialize the data object.""" - self._hass = hass - self._entry = entry - - self._url = entry.data[CONF_URL] - self._tokens = entry.data[CONF_TOKEN] - - self.entry_id = entry.entry_id - self.unique_id = entry.unique_id - - self.token_refresh_failure = False - self.incident_id = None - self.on_duty = False - - self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) - - self.oauth = FireServiceRotaOauth( - self._hass, - self._entry, - self.fsr, - ) - - self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) - - async def setup(self) -> None: - """Set up the data client.""" - await self._hass.async_add_executor_job(self.websocket.start_listener) - - async def update_call(self, func, *args): - """Perform update call and return data.""" - if self.token_refresh_failure: - return None - - try: - return await self._hass.async_add_executor_job(func, *args) - except (ExpiredTokenError, InvalidTokenError): - await self._hass.async_add_executor_job(self.websocket.stop_listener) - self.token_refresh_failure = True - - if await self.oauth.async_refresh_tokens(): - self.token_refresh_failure = False - await self._hass.async_add_executor_job(self.websocket.start_listener) - - return await self._hass.async_add_executor_job(func, *args) - - async def async_update(self) -> dict | None: - """Get the latest availability data.""" - data = await self.update_call( - self.fsr.get_availability, str(self._hass.config.time_zone) - ) - - if not data: - return None - - self.on_duty = bool(data.get("available")) - - _LOGGER.debug("Updated availability data: %s", data) - return data - - async def async_response_update(self) -> dict | None: - """Get the latest incident response data.""" - - if not self.incident_id: - return None - - _LOGGER.debug("Updating response data for incident id %s", self.incident_id) - - return await self.update_call(self.fsr.get_incident_response, self.incident_id) - - async def async_set_response(self, value) -> None: - """Set incident response status.""" - - if not self.incident_id: - return - - _LOGGER.debug( - "Setting incident response for incident id '%s' to state '%s'", - self.incident_id, - value, - ) - - await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index a22991f2008..b6d3aa67a0a 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -8,13 +8,10 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FireServiceRotaClient from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator async def async_setup_entry( @@ -26,14 +23,16 @@ async def async_setup_entry( DATA_CLIENT ] - coordinator: DataUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ + coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ entry.entry_id ][DATA_COORDINATOR] async_add_entities([ResponseBinarySensor(coordinator, client, entry)]) -class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): +class ResponseBinarySensor( + CoordinatorEntity[FireServiceUpdateCoordinator], BinarySensorEntity +): """Representation of an FireServiceRota sensor.""" _attr_has_entity_name = True @@ -41,7 +40,7 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: FireServiceUpdateCoordinator, client: FireServiceRotaClient, entry: ConfigEntry, ) -> None: diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py new file mode 100644 index 00000000000..35f839b3bdb --- /dev/null +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -0,0 +1,213 @@ +"""The FireServiceRota integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyfireservicerota import ( + ExpiredTokenError, + FireServiceRota, + FireServiceRotaIncidents, + InvalidAuthError, + InvalidTokenError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, WSS_BWRURL + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + + +class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]): + """Data update coordinator for FireServiceRota.""" + + def __init__( + self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry + ) -> None: + """Initialize the FireServiceRota DataUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="duty binary sensor", + config_entry=entry, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + self.client = client + + async def _async_update_data(self) -> dict | None: + """Get the latest availability data.""" + return await self.client.async_update() + + +class FireServiceRotaOauth: + """Handle authentication tokens.""" + + def __init__(self, hass, entry, fsr): + """Initialize the oauth object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._username = entry.data[CONF_USERNAME] + self._fsr = fsr + + async def async_refresh_tokens(self) -> bool: + """Refresh tokens and update config entry.""" + _LOGGER.debug("Refreshing authentication tokens after expiration") + + try: + token_info = await self._hass.async_add_executor_job( + self._fsr.refresh_tokens + ) + + except (InvalidAuthError, InvalidTokenError) as err: + raise ConfigEntryAuthFailed( + "Error refreshing tokens, triggered reauth workflow" + ) from err + + _LOGGER.debug("Saving new tokens in config entry") + self._hass.config_entries.async_update_entry( + self._entry, + data={ + "auth_implementation": DOMAIN, + CONF_URL: self._url, + CONF_USERNAME: self._username, + CONF_TOKEN: token_info, + }, + ) + + return True + + +class FireServiceRotaWebSocket: + """Define a FireServiceRota websocket manager object.""" + + def __init__(self, hass, entry): + """Initialize the websocket object.""" + self._hass = hass + self._entry = entry + + self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) + self.incident_data = None + + def _construct_url(self) -> str: + """Return URL with latest access token.""" + return WSS_BWRURL.format( + self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] + ) + + def _on_incident(self, data) -> None: + """Received new incident, update data.""" + _LOGGER.debug("Received new incident via websocket: %s", data) + self.incident_data = data + dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") + + def start_listener(self) -> None: + """Start the websocket listener.""" + _LOGGER.debug("Starting incidents listener") + self._fsr_incidents.start(self._construct_url()) + + def stop_listener(self) -> None: + """Stop the websocket listener.""" + _LOGGER.debug("Stopping incidents listener") + self._fsr_incidents.stop() + + +class FireServiceRotaClient: + """Getting the latest data from fireservicerota.""" + + def __init__(self, hass, entry): + """Initialize the data object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._tokens = entry.data[CONF_TOKEN] + + self.entry_id = entry.entry_id + self.unique_id = entry.unique_id + + self.token_refresh_failure = False + self.incident_id = None + self.on_duty = False + + self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) + + self.oauth = FireServiceRotaOauth( + self._hass, + self._entry, + self.fsr, + ) + + self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) + + async def setup(self) -> None: + """Set up the data client.""" + await self._hass.async_add_executor_job(self.websocket.start_listener) + + async def update_call(self, func, *args): + """Perform update call and return data.""" + if self.token_refresh_failure: + return None + + try: + return await self._hass.async_add_executor_job(func, *args) + except (ExpiredTokenError, InvalidTokenError): + await self._hass.async_add_executor_job(self.websocket.stop_listener) + self.token_refresh_failure = True + + if await self.oauth.async_refresh_tokens(): + self.token_refresh_failure = False + await self._hass.async_add_executor_job(self.websocket.start_listener) + + return await self._hass.async_add_executor_job(func, *args) + + async def async_update(self) -> dict | None: + """Get the latest availability data.""" + data = await self.update_call( + self.fsr.get_availability, str(self._hass.config.time_zone) + ) + + if not data: + return None + + self.on_duty = bool(data.get("available")) + + _LOGGER.debug("Updated availability data: %s", data) + return data + + async def async_response_update(self) -> dict | None: + """Get the latest incident response data.""" + + if not self.incident_id: + return None + + _LOGGER.debug("Updating response data for incident id %s", self.incident_id) + + return await self.update_call(self.fsr.get_incident_response, self.incident_id) + + async def async_set_response(self, value) -> None: + """Set incident response status.""" + + if not self.incident_id: + return + + _LOGGER.debug( + "Setting incident response for incident id '%s' to state '%s'", + self.incident_id, + value, + ) + + await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py index 5555a8d649c..8d150034ec9 100644 --- a/tests/components/fireservicerota/test_config_flow.py +++ b/tests/components/fireservicerota/test_config_flow.py @@ -66,7 +66,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test that invalid credentials throws an error.""" with patch( - "homeassistant.components.fireservicerota.FireServiceRota.request_tokens", + "homeassistant.components.fireservicerota.coordinator.FireServiceRota.request_tokens", side_effect=InvalidAuthError, ): result = await hass.config_entries.flow.async_init( From d5dd0f6ec1f7442184782c2fd6b2a7119b8656d5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Feb 2025 11:02:01 -0600 Subject: [PATCH 212/288] 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 213/288] 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 214/288] 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 15bc29f8cac18ed22583b7c1a749050dfbc3f74c Mon Sep 17 00:00:00 2001 From: Jan Rieger <271149+jrieger@users.noreply.github.com> Date: Wed, 5 Feb 2025 18:34:43 +0100 Subject: [PATCH 215/288] Add GPSd satellites sensors (#137320) --- homeassistant/components/gpsd/icons.json | 6 ++++ homeassistant/components/gpsd/sensor.py | 36 ++++++++++++++++++++++ homeassistant/components/gpsd/strings.json | 8 +++++ 3 files changed, 50 insertions(+) diff --git a/homeassistant/components/gpsd/icons.json b/homeassistant/components/gpsd/icons.json index 59d904f918c..3605bdc6d70 100644 --- a/homeassistant/components/gpsd/icons.json +++ b/homeassistant/components/gpsd/icons.json @@ -16,6 +16,12 @@ }, "elevation": { "default": "mdi:arrow-up-down" + }, + "total_satellites": { + "default": "mdi:satellite-variant" + }, + "used_satellites": { + "default": "mdi:satellite-variant" } } } diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 1bac41ecaae..70d32f88a65 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ATTR_LATITUDE, @@ -39,12 +40,31 @@ ATTR_CLIMB = "climb" ATTR_ELEVATION = "elevation" ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" +ATTR_TOTAL_SATELLITES = "total_satellites" +ATTR_USED_SATELLITES = "used_satellites" DEFAULT_NAME = "GPS" _MODE_VALUES = {2: "2d_fix", 3: "3d_fix"} +def count_total_satellites_fn(agps_thread: AGPS3mechanism) -> int | None: + """Count the number of total satellites.""" + satellites = agps_thread.data_stream.satellites + return None if satellites == "n/a" else len(satellites) + + +def count_used_satellites_fn(agps_thread: AGPS3mechanism) -> int | None: + """Count the number of used satellites.""" + satellites = agps_thread.data_stream.satellites + if satellites == "n/a": + return None + + return sum( + 1 for sat in satellites if isinstance(sat, dict) and sat.get("used", False) + ) + + @dataclass(frozen=True, kw_only=True) class GpsdSensorDescription(SensorEntityDescription): """Class describing GPSD sensor entities.""" @@ -116,6 +136,22 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = ( suggested_display_precision=2, entity_registry_enabled_default=False, ), + GpsdSensorDescription( + key=ATTR_TOTAL_SATELLITES, + translation_key=ATTR_TOTAL_SATELLITES, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=count_total_satellites_fn, + entity_registry_enabled_default=False, + ), + GpsdSensorDescription( + key=ATTR_USED_SATELLITES, + translation_key=ATTR_USED_SATELLITES, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=count_used_satellites_fn, + entity_registry_enabled_default=False, + ), ) diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json index 867edf0b5a8..a5d6c570b54 100644 --- a/homeassistant/components/gpsd/strings.json +++ b/homeassistant/components/gpsd/strings.json @@ -50,6 +50,14 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]" } } + }, + "total_satellites": { + "name": "Total satellites", + "unit_of_measurement": "satellites" + }, + "used_satellites": { + "name": "Used satellites", + "unit_of_measurement": "satellites" } } } From 3a88c9d6f45b8655c5422734acada258387fe094 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 17:35:07 +0000 Subject: [PATCH 216/288] 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" From 94614e0376bfa301841f1350401ec7b9e701ef63 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 6 Feb 2025 08:01:45 +1300 Subject: [PATCH 217/288] Change Electric Kiwi authentication (#135231) Co-authored-by: Joostlek --- .../components/electric_kiwi/__init__.py | 64 +++++- homeassistant/components/electric_kiwi/api.py | 26 ++- .../components/electric_kiwi/config_flow.py | 37 +++- .../components/electric_kiwi/const.py | 2 +- .../components/electric_kiwi/coordinator.py | 18 +- .../components/electric_kiwi/manifest.json | 2 +- .../components/electric_kiwi/select.py | 4 +- .../components/electric_kiwi/sensor.py | 24 ++- .../components/electric_kiwi/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/electric_kiwi/__init__.py | 12 ++ tests/components/electric_kiwi/conftest.py | 162 +++++++++----- .../fixtures/account_balance.json | 28 --- .../fixtures/account_summary.json | 43 ++++ .../fixtures/connection_details.json | 73 +++++++ .../electric_kiwi/fixtures/get_hop.json | 20 +- .../electric_kiwi/fixtures/hop_intervals.json | 199 +++++++++--------- .../electric_kiwi/fixtures/session.json | 23 ++ .../fixtures/session_no_services.json | 16 ++ .../electric_kiwi/test_config_flow.py | 127 ++++++----- tests/components/electric_kiwi/test_init.py | 135 ++++++++++++ tests/components/electric_kiwi/test_sensor.py | 27 ++- 23 files changed, 753 insertions(+), 296 deletions(-) delete mode 100644 tests/components/electric_kiwi/fixtures/account_balance.json create mode 100644 tests/components/electric_kiwi/fixtures/account_summary.json create mode 100644 tests/components/electric_kiwi/fixtures/connection_details.json create mode 100644 tests/components/electric_kiwi/fixtures/session.json create mode 100644 tests/components/electric_kiwi/fixtures/session_no_services.json create mode 100644 tests/components/electric_kiwi/test_init.py diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index de8d87553a3..825dbc54013 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -4,12 +4,16 @@ from __future__ import annotations import aiohttp from electrickiwi_api import ElectricKiwiApi -from electrickiwi_api.exceptions import ApiException +from electrickiwi_api.exceptions import ApiException, AuthException from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + entity_registry as er, +) from . import api from .coordinator import ( @@ -44,7 +48,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err ek_api = ElectricKiwiApi( - api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) ) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) @@ -53,6 +59,8 @@ async def async_setup_entry( await ek_api.set_active_session() await hop_coordinator.async_config_entry_first_refresh() await account_coordinator.async_config_entry_first_refresh() + except AuthException as err: + raise ConfigEntryAuthFailed from err except ApiException as err: raise ConfigEntryNotReady from err @@ -70,3 +78,53 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry +) -> bool: + """Migrate old entry.""" + if config_entry.version == 1 and config_entry.minor_version == 1: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + + ek_api = ElectricKiwiApi( + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + ) + try: + await ek_api.set_active_session() + connection_details = await ek_api.get_connection_details() + except AuthException: + config_entry.async_start_reauth(hass) + return False + except ApiException: + return False + unique_id = str(ek_api.customer_number) + identifier = ek_api.electricity.identifier + hass.config_entries.async_update_entry( + config_entry, unique_id=unique_id, minor_version=2 + ) + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + + for entity in entity_entries: + assert entity.config_entry_id + entity_registry.async_update_entity( + entity.entity_id, + new_unique_id=entity.unique_id.replace( + f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}" + ), + ) + + return True diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py index dead8a6a3c0..9f7ff333378 100644 --- a/homeassistant/components/electric_kiwi/api.py +++ b/homeassistant/components/electric_kiwi/api.py @@ -2,17 +2,16 @@ from __future__ import annotations -from typing import cast - from aiohttp import ClientSession from electrickiwi_api import AbstractAuth -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import API_BASE_URL -class AsyncConfigEntryAuth(AbstractAuth): +class ConfigEntryElectricKiwiAuth(AbstractAuth): """Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" def __init__( @@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth): """Return a valid access token.""" await self._oauth_session.async_ensure_token_valid() - return cast(str, self._oauth_session.token["access_token"]) + return str(self._oauth_session.token["access_token"]) + + +class ConfigFlowElectricKiwiAuth(AbstractAuth): + """Provide Electric Kiwi authentication tied to an OAuth2 based config flow.""" + + def __init__( + self, + hass: HomeAssistant, + token: str, + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Electric Kiwi API.""" + return self._token diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py index b74ab4268e2..b83fd89c4c6 100644 --- a/homeassistant/components/electric_kiwi/config_flow.py +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -6,9 +6,14 @@ from collections.abc import Mapping import logging from typing import Any -from homeassistant.config_entries import ConfigFlowResult +from electrickiwi_api import ElectricKiwiApi +from electrickiwi_api.exceptions import ApiException + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_entry_oauth2_flow +from . import api from .const import DOMAIN, SCOPE_VALUES @@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler( ): """Config flow to handle Electric Kiwi OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler( ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) return await self.async_step_user() async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for Electric Kiwi.""" - existing_entry = await self.async_set_unique_id(DOMAIN) - if existing_entry: - return self.async_update_reload_and_abort(existing_entry, data=data) - return await super().async_oauth_create_entry(data) + ek_api = ElectricKiwiApi( + api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"]) + ) + + try: + session = await ek_api.get_active_session() + except ApiException: + return self.async_abort(reason="connection_error") + + unique_id = str(session.data.customer_number) + await self.async_set_unique_id(unique_id) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=unique_id, data=data) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 907b6247172..c51422a7c72 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" -SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" +SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 2065da5d668..635b55b2bc0 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -10,7 +10,7 @@ import logging from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import AccountSummary, Hop, HopIntervals from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData: type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] -class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]): """ElectricKiwi Account Data object.""" def __init__( @@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api - async def _async_update_data(self) -> AccountBalance: + async def _async_update_data(self) -> AccountSummary: """Fetch data from Account balance API endpoint.""" try: async with asyncio.timeout(60): - return await self._ek_api.get_account_balance() + return await self.ek_api.get_account_summary() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): # Polling interval. Will only be polled if there are subscribers. update_interval=HOP_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api self.hop_intervals: HopIntervals | None = None def get_hop_options(self) -> dict[str, int]: @@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): async def async_update_hop(self, hop_interval: int) -> Hop: """Update selected hop and data.""" try: - self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) + self.async_set_updated_data(await self.ek_api.post_hop(hop_interval)) except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): try: async with asyncio.timeout(60): if self.hop_intervals is None: - hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() + hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals() hop_intervals.intervals = OrderedDict( filter( lambda pair: pair[1].active == 1, @@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): ) self.hop_intervals = hop_intervals - return await self._ek_api.get_hop() + return await self.ek_api.get_hop() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 8ddb4c1af7c..9afe487d368 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.8.5"] + "requirements": ["electrickiwi-api==0.9.12"] } diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index fa111381612..30e02b5c5b9 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity( """Initialise the HOP selection entity.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description self.values_dict = coordinator.get_hop_options() diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index e070f9495c1..410d70808c3 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from electrickiwi_api.model import AccountBalance, Hop +from electrickiwi_api.model import AccountSummary, Hop from homeassistant.components.sensor import ( SensorDeviceClass, @@ -39,7 +39,15 @@ ATTR_HOP_PERCENTAGE = "hop_percentage" class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription): """Describes Electric Kiwi sensor entity.""" - value_func: Callable[[AccountBalance], float | datetime] + value_func: Callable[[AccountSummary], float | datetime] + + +def _get_hop_percentage(account_balance: AccountSummary) -> float: + """Return the hop percentage from account summary.""" + if power := account_balance.services.get("power"): + if connection := power.connections[0]: + return float(connection.hop_percentage) + return 0.0 ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( @@ -72,9 +80,7 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( translation_key="hop_power_savings", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - value_func=lambda account_balance: float( - account_balance.connections[0].hop_percentage - ), + value_func=_get_hop_percentage, ), ) @@ -165,8 +171,8 @@ class ElectricKiwiAccountEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description @@ -194,8 +200,8 @@ class ElectricKiwiHOPEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 410d32909ba..5e0a2ef168d 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -21,7 +21,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/requirements_all.txt b/requirements_all.txt index f637bf8e5bd..284afccb5f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.5 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.12 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f95171794aa..07ddbd78fb9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -702,7 +702,7 @@ easyenergy==2.1.2 eheimdigital==1.0.5 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.12 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py index 7f5e08a56b5..936557ac3bf 100644 --- a/tests/components/electric_kiwi/__init__.py +++ b/tests/components/electric_kiwi/__init__.py @@ -1 +1,13 @@ """Tests for the Electric Kiwi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Fixture for setting up the integration with args.""" + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 010efcb7b5f..cc967631be4 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -2,11 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from time import time from unittest.mock import AsyncMock, patch -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import ( + AccountSummary, + CustomerConnection, + Hop, + HopIntervals, + Service, + Session, +) import pytest from homeassistant.components.application_credentials import ( @@ -23,37 +30,55 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -type YieldFixture = Generator[AsyncMock] -type ComponentSetup = Callable[[], Awaitable[bool]] + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host: None) -> None: - """Request setup.""" - - -@pytest.fixture -def component_setup( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> ComponentSetup: - """Fixture for setting up the integration.""" - - async def _setup_func() -> bool: - assert await async_setup_component(hass, "application_credentials", {}) - await hass.async_block_till_done() - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, +def electrickiwi_api() -> Generator[AsyncMock]: + """Mock ek api and return values.""" + with ( + patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.electric_kiwi.config_flow.ElectricKiwiApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.customer_number = 123456 + client.electricity = Service( + identifier="00000000DDA", + service="electricity", + service_status="Y", + is_primary_service=True, ) - await hass.async_block_till_done() - config_entry.add_to_hass(hass) - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return _setup_func + client.get_active_session.return_value = Session.from_dict( + load_json_value_fixture("session.json", DOMAIN) + ) + client.get_hop_intervals.return_value = HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + client.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + client.get_account_summary.return_value = AccountSummary.from_dict( + load_json_value_fixture("account_summary.json", DOMAIN) + ) + client.get_connection_details.return_value = CustomerConnection.from_dict( + load_json_value_fixture("connection_details.json", DOMAIN) + ) + yield client @pytest.fixture(name="config_entry") @@ -63,7 +88,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "12345", + "id": "123456", "auth_implementation": DOMAIN, "token": { "refresh_token": "mock-refresh-token", @@ -74,6 +99,54 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, }, unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="config_entry2") +def mock_config_entry2(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123457", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="1234567", + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="migrated_config_entry") +def mock_migrated_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123456", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="123456", + version=1, + minor_version=2, ) @@ -87,35 +160,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="ek_auth") -def electric_kiwi_auth() -> YieldFixture: +def electric_kiwi_auth() -> Generator[AsyncMock]: """Patch access to electric kiwi access token.""" with patch( - "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + "homeassistant.components.electric_kiwi.api.ConfigEntryElectricKiwiAuth" ) as mock_auth: mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") yield mock_auth - - -@pytest.fixture(name="ek_api") -def ek_api() -> YieldFixture: - """Mock ek api and return values.""" - with patch( - "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True - ) as mock_ek_api: - mock_ek_api.return_value.customer_number = 123456 - mock_ek_api.return_value.connection_id = 123456 - mock_ek_api.return_value.set_active_session.return_value = None - mock_ek_api.return_value.get_hop_intervals.return_value = ( - HopIntervals.from_dict( - load_json_value_fixture("hop_intervals.json", DOMAIN) - ) - ) - mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( - load_json_value_fixture("get_hop.json", DOMAIN) - ) - mock_ek_api.return_value.get_account_balance.return_value = ( - AccountBalance.from_dict( - load_json_value_fixture("account_balance.json", DOMAIN) - ) - ) - yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json deleted file mode 100644 index 25bc57784ee..00000000000 --- a/tests/components/electric_kiwi/fixtures/account_balance.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "data": { - "connections": [ - { - "hop_percentage": "3.5", - "id": 3, - "running_balance": "184.09", - "start_date": "2020-10-04", - "unbilled_days": 15 - } - ], - "last_billed_amount": "-66.31", - "last_billed_date": "2020-10-03", - "next_billing_date": "2020-11-03", - "is_prepay": "N", - "summary": { - "credits": "0.0", - "electricity_used": "184.09", - "other_charges": "0.00", - "payments": "-220.0" - }, - "total_account_balance": "-102.22", - "total_billing_days": 30, - "total_running_balance": "184.09", - "type": "account_running_balance" - }, - "status": 1 -} diff --git a/tests/components/electric_kiwi/fixtures/account_summary.json b/tests/components/electric_kiwi/fixtures/account_summary.json new file mode 100644 index 00000000000..6a05d6a3fe7 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_summary.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "account_summary", + "total_running_balance": "184.09", + "total_account_balance": "-102.22", + "total_billing_days": 31, + "next_billing_date": "2025-02-19", + "service_names": ["power"], + "services": { + "power": { + "connections": [ + { + "id": 515363, + "running_balance": "12.98", + "unbilled_days": 5, + "hop_percentage": "11.2", + "start_date": "2025-01-19", + "service_label": "Power" + } + ] + } + }, + "date_to_pay": "", + "invoice_id": "", + "total_invoiced_charges": "", + "default_to_pay": "", + "invoice_exists": 1, + "display_date": "2025-01-19", + "last_billed_date": "2025-01-18", + "last_billed_amount": "-21.02", + "summary": { + "electricity_used": "12.98", + "other_charges": "0.00", + "payments": "0.00", + "credits": "0.00", + "mobile_charges": "0.00", + "broadband_charges": "0.00", + "addon_unbilled_charges": {} + }, + "is_prepay": "N" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/connection_details.json b/tests/components/electric_kiwi/fixtures/connection_details.json new file mode 100644 index 00000000000..5b446659aab --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/connection_details.json @@ -0,0 +1,73 @@ +{ + "data": { + "type": "connection", + "id": 515363, + "customer_id": 273941, + "customer_number": 34030646, + "icp_identifier": "00000000DDA", + "address": "", + "short_address": "", + "physical_address_unit": "", + "physical_address_number": "555", + "physical_address_street": "RACECOURSE ROAD", + "physical_address_suburb": "", + "physical_address_town": "Blah", + "physical_address_region": "Blah", + "physical_address_postcode": "0000", + "is_active": "Y", + "pricing_plan": { + "id": 51423, + "usage": "0.0000", + "fixed": "0.6000", + "usage_rate_inc_gst": "0.0000", + "supply_rate_inc_gst": "0.6900", + "plan_description": "MoveMaster Anytime Residential (Low User)", + "plan_type": "movemaster_tou", + "signup_price_plan_blurb": "Better rates every day during off-peak, and all day on weekends. Plus half price nights (11pm-7am) and our best solar buyback.", + "signup_price_plan_label": "MoveMaster", + "app_price_plan_label": "Your MoveMaster rates are...", + "solar_rate_excl_gst": "0.1250", + "solar_rate_incl_gst": "0.1438", + "pricing_type": "tou_plus", + "tou_plus": { + "fixed_rate_excl_gst": "0.6000", + "fixed_rate_incl_gst": "0.6900", + "interval_types": ["peak", "off_peak_shoulder", "off_peak_night"], + "peak": { + "price_excl_gst": "0.5390", + "price_incl_gst": "0.6199", + "display_text": { + "Weekdays": "7am-9am, 5pm-9pm" + }, + "tou_plus_label": "Peak" + }, + "off_peak_shoulder": { + "price_excl_gst": "0.3234", + "price_incl_gst": "0.3719", + "display_text": { + "Weekdays": "9am-5pm, 9pm-11pm", + "Weekends": "7am-11pm" + }, + "tou_plus_label": "Off-peak shoulder" + }, + "off_peak_night": { + "price_excl_gst": "0.2695", + "price_incl_gst": "0.3099", + "display_text": { + "Every day": "11pm-7am" + }, + "tou_plus_label": "Off-peak night" + } + } + }, + "hop": { + "start_time": "9:00 PM", + "end_time": "10:00 PM", + "interval_start": "43", + "interval_end": "44" + }, + "start_date": "2022-03-03", + "end_date": "", + "property_type": "residential" + } +} diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json index d29825391e9..2b126bfc017 100644 --- a/tests/components/electric_kiwi/fixtures/get_hop.json +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -1,16 +1,18 @@ { "data": { - "connection_id": "3", - "customer_number": 1000001, - "end": { - "end_time": "5:00 PM", - "interval": "34" - }, + "type": "hop_customer", + "customer_id": 123456, + "service_type": "electricity", + "connection_id": 515363, + "billing_id": 1247975, "start": { - "start_time": "4:00 PM", - "interval": "33" + "interval": "33", + "start_time": "4:00 PM" }, - "type": "hop_customer" + "end": { + "interval": "34", + "end_time": "5:00 PM" + } }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json index 15ecc174f13..860630b000a 100644 --- a/tests/components/electric_kiwi/fixtures/hop_intervals.json +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -1,249 +1,250 @@ { "data": { - "hop_duration": "60", "type": "hop_intervals", + "hop_duration": "60", "intervals": { "1": { - "active": 1, + "start_time": "12:00 AM", "end_time": "1:00 AM", - "start_time": "12:00 AM" + "active": 1 }, "2": { - "active": 1, + "start_time": "12:30 AM", "end_time": "1:30 AM", - "start_time": "12:30 AM" + "active": 1 }, "3": { - "active": 1, + "start_time": "1:00 AM", "end_time": "2:00 AM", - "start_time": "1:00 AM" + "active": 1 }, "4": { - "active": 1, + "start_time": "1:30 AM", "end_time": "2:30 AM", - "start_time": "1:30 AM" + "active": 1 }, "5": { - "active": 1, + "start_time": "2:00 AM", "end_time": "3:00 AM", - "start_time": "2:00 AM" + "active": 1 }, "6": { - "active": 1, + "start_time": "2:30 AM", "end_time": "3:30 AM", - "start_time": "2:30 AM" + "active": 1 }, "7": { - "active": 1, + "start_time": "3:00 AM", "end_time": "4:00 AM", - "start_time": "3:00 AM" + "active": 1 }, "8": { - "active": 1, + "start_time": "3:30 AM", "end_time": "4:30 AM", - "start_time": "3:30 AM" + "active": 1 }, "9": { - "active": 1, + "start_time": "4:00 AM", "end_time": "5:00 AM", - "start_time": "4:00 AM" + "active": 1 }, "10": { - "active": 1, + "start_time": "4:30 AM", "end_time": "5:30 AM", - "start_time": "4:30 AM" + "active": 1 }, "11": { - "active": 1, + "start_time": "5:00 AM", "end_time": "6:00 AM", - "start_time": "5:00 AM" + "active": 1 }, "12": { - "active": 1, + "start_time": "5:30 AM", "end_time": "6:30 AM", - "start_time": "5:30 AM" + "active": 1 }, "13": { - "active": 1, + "start_time": "6:00 AM", "end_time": "7:00 AM", - "start_time": "6:00 AM" + "active": 1 }, "14": { - "active": 1, + "start_time": "6:30 AM", "end_time": "7:30 AM", - "start_time": "6:30 AM" + "active": 0 }, "15": { - "active": 1, + "start_time": "7:00 AM", "end_time": "8:00 AM", - "start_time": "7:00 AM" + "active": 0 }, "16": { - "active": 1, + "start_time": "7:30 AM", "end_time": "8:30 AM", - "start_time": "7:30 AM" + "active": 0 }, "17": { - "active": 1, + "start_time": "8:00 AM", "end_time": "9:00 AM", - "start_time": "8:00 AM" + "active": 0 }, "18": { - "active": 1, + "start_time": "8:30 AM", "end_time": "9:30 AM", - "start_time": "8:30 AM" + "active": 0 }, "19": { - "active": 1, + "start_time": "9:00 AM", "end_time": "10:00 AM", - "start_time": "9:00 AM" + "active": 1 }, "20": { - "active": 1, + "start_time": "9:30 AM", "end_time": "10:30 AM", - "start_time": "9:30 AM" + "active": 1 }, "21": { - "active": 1, + "start_time": "10:00 AM", "end_time": "11:00 AM", - "start_time": "10:00 AM" + "active": 1 }, "22": { - "active": 1, + "start_time": "10:30 AM", "end_time": "11:30 AM", - "start_time": "10:30 AM" + "active": 1 }, "23": { - "active": 1, + "start_time": "11:00 AM", "end_time": "12:00 PM", - "start_time": "11:00 AM" + "active": 1 }, "24": { - "active": 1, + "start_time": "11:30 AM", "end_time": "12:30 PM", - "start_time": "11:30 AM" + "active": 1 }, "25": { - "active": 1, + "start_time": "12:00 PM", "end_time": "1:00 PM", - "start_time": "12:00 PM" + "active": 1 }, "26": { - "active": 1, + "start_time": "12:30 PM", "end_time": "1:30 PM", - "start_time": "12:30 PM" + "active": 1 }, "27": { - "active": 1, + "start_time": "1:00 PM", "end_time": "2:00 PM", - "start_time": "1:00 PM" + "active": 1 }, "28": { - "active": 1, + "start_time": "1:30 PM", "end_time": "2:30 PM", - "start_time": "1:30 PM" + "active": 1 }, "29": { - "active": 1, + "start_time": "2:00 PM", "end_time": "3:00 PM", - "start_time": "2:00 PM" + "active": 1 }, "30": { - "active": 1, + "start_time": "2:30 PM", "end_time": "3:30 PM", - "start_time": "2:30 PM" + "active": 1 }, "31": { - "active": 1, + "start_time": "3:00 PM", "end_time": "4:00 PM", - "start_time": "3:00 PM" + "active": 1 }, "32": { - "active": 1, + "start_time": "3:30 PM", "end_time": "4:30 PM", - "start_time": "3:30 PM" + "active": 1 }, "33": { - "active": 1, + "start_time": "4:00 PM", "end_time": "5:00 PM", - "start_time": "4:00 PM" + "active": 1 }, "34": { - "active": 1, + "start_time": "4:30 PM", "end_time": "5:30 PM", - "start_time": "4:30 PM" + "active": 0 }, "35": { - "active": 1, + "start_time": "5:00 PM", "end_time": "6:00 PM", - "start_time": "5:00 PM" + "active": 0 }, "36": { - "active": 1, + "start_time": "5:30 PM", "end_time": "6:30 PM", - "start_time": "5:30 PM" + "active": 0 }, "37": { - "active": 1, + "start_time": "6:00 PM", "end_time": "7:00 PM", - "start_time": "6:00 PM" + "active": 0 }, "38": { - "active": 1, + "start_time": "6:30 PM", "end_time": "7:30 PM", - "start_time": "6:30 PM" + "active": 0 }, "39": { - "active": 1, + "start_time": "7:00 PM", "end_time": "8:00 PM", - "start_time": "7:00 PM" + "active": 0 }, "40": { - "active": 1, + "start_time": "7:30 PM", "end_time": "8:30 PM", - "start_time": "7:30 PM" + "active": 0 }, "41": { - "active": 1, + "start_time": "8:00 PM", "end_time": "9:00 PM", - "start_time": "8:00 PM" + "active": 0 }, "42": { - "active": 1, + "start_time": "8:30 PM", "end_time": "9:30 PM", - "start_time": "8:30 PM" + "active": 0 }, "43": { - "active": 1, + "start_time": "9:00 PM", "end_time": "10:00 PM", - "start_time": "9:00 PM" + "active": 1 }, "44": { - "active": 1, + "start_time": "9:30 PM", "end_time": "10:30 PM", - "start_time": "9:30 PM" + "active": 1 }, "45": { - "active": 1, - "end_time": "11:00 AM", - "start_time": "10:00 PM" + "start_time": "10:00 PM", + "end_time": "11:00 PM", + "active": 1 }, "46": { - "active": 1, + "start_time": "10:30 PM", "end_time": "11:30 PM", - "start_time": "10:30 PM" + "active": 1 }, "47": { - "active": 1, + "start_time": "11:00 PM", "end_time": "12:00 AM", - "start_time": "11:00 PM" + "active": 1 }, "48": { - "active": 1, + "start_time": "11:30 PM", "end_time": "12:30 AM", - "start_time": "11:30 PM" + "active": 0 } - } + }, + "service_type": "electricity" }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/session.json b/tests/components/electric_kiwi/fixtures/session.json new file mode 100644 index 00000000000..ee04aaca549 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session.json @@ -0,0 +1,23 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [ + { + "service": "Electricity", + "identifier": "00000000DDA", + "is_primary_service": true, + "service_status": "Y" + } + ], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/session_no_services.json b/tests/components/electric_kiwi/fixtures/session_no_services.json new file mode 100644 index 00000000000..62ae7aea20a --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session_no_services.json @@ -0,0 +1,16 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 681320972b5..ab643a0ddf1 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -3,70 +3,40 @@ from __future__ import annotations from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from electrickiwi_api.exceptions import ApiException import pytest -from homeassistant import config_entries -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.electric_kiwi.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, SCOPE_VALUES, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component -from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +from .conftest import CLIENT_ID, REDIRECT_URI from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup application credentials component.""" - await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: - """Test config flow base case with no credentials registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "electrickiwi_api") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" - await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) - ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -76,13 +46,13 @@ async def test_full_flow( }, ) - URL_SCOPE = SCOPE_VALUES.replace(" ", "+") + url_scope = SCOPE_VALUES.replace(" ", "+") assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}" f"&state={state}" - f"&scope={URL_SCOPE}" + f"&scope={url_scope}" ) client = await hass_client_no_auth() @@ -90,6 +60,7 @@ async def test_full_flow( assert resp.status == HTTPStatus.OK assert resp.headers["content-type"] == "text/html; charset=utf-8" + aioclient_mock.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -106,20 +77,73 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + electrickiwi_api: AsyncMock, +) -> None: + """Check failure on creation of entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + url_scope = SCOPE_VALUES.replace(" ", "+") + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + f"&scope={url_scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + electrickiwi_api.get_active_session.side_effect = ApiException() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "connection_error" + + @pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - config_entry: MockConfigEntry, + migrated_config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - config_entry.add_to_hass(hass) + migrated_config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER, "entry_id": DOMAIN} ) state = config_entry_oauth2_flow._encode_jwt( @@ -145,7 +169,9 @@ async def test_existing_entry( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -154,13 +180,13 @@ async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, - config_entry: MockConfigEntry, - setup_credentials: None, + mock_setup_entry: AsyncMock, + migrated_config_entry: MockConfigEntry, ) -> None: """Test Electric Kiwi reauthentication.""" - config_entry.add_to_hass(hass) - result = await config_entry.start_reauth_flow(hass) + migrated_config_entry.add_to_hass(hass) + + result = await migrated_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -189,8 +215,11 @@ async def test_reauthentication( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" diff --git a/tests/components/electric_kiwi/test_init.py b/tests/components/electric_kiwi/test_init.py new file mode 100644 index 00000000000..947f788ad55 --- /dev/null +++ b/tests/components/electric_kiwi/test_init.py @@ -0,0 +1,135 @@ +"""Test the Electric Kiwi init.""" + +import http +from unittest.mock import AsyncMock, patch + +from aiohttp import RequestInfo +from aiohttp.client_exceptions import ClientResponseError +from electrickiwi_api.exceptions import ApiException, AuthException +import pytest + +from homeassistant.components.electric_kiwi.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + await init_integration(hass, config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_multiple_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, + config_entry2: MockConfigEntry, +) -> None: + """Test a successful setup and unload of multiple entries.""" + + for entry in (config_entry, config_entry2): + await init_integration(hass, entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + for entry in (config_entry, config_entry2): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + ( + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_refresh_token_validity_failures( + hass: HomeAssistant, + config_entry: MockConfigEntry, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test token refresh failure status.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + RequestInfo("", "POST", {}, ""), None, status=status + ), + ) as mock_async_ensure_token_valid: + await init_integration(hass, config_entry) + mock_async_ensure_token_valid.assert_called_once() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_unique_id_migration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID is migrated to the customer number.""" + + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, "123456_515363_sensor", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + new_entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert new_entry.minor_version == 2 + assert new_entry.unique_id == "123456" + entity_entry = entity_registry.async_get( + "sensor.electric_kiwi_123456_515363_sensor" + ) + assert entity_entry.unique_id == "123456_00000000DDA_sensor" + + +async def test_unique_id_migration_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = ApiException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_unique_id_migration_auth_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = AuthException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index a85eb16a986..3e58b33a998 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import dt as dt_util -from .conftest import ComponentSetup, YieldFixture +from . import init_integration from tests.common import MockConfigEntry @@ -47,10 +47,9 @@ def restore_timezone(): async def test_hop_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: Mock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, ) -> None: @@ -61,7 +60,7 @@ async def test_hop_sensors( sensor state should be set to today at 4pm or if now is past 4pm, then tomorrow at 4pm. """ - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -70,8 +69,7 @@ async def test_hop_sensors( state = hass.states.get(sensor) assert state - api = ek_api(Mock()) - hop_data = await api.get_hop() + hop_data = await electrickiwi_api.get_hop() value = _check_and_move_time(hop_data, sensor_state) @@ -98,20 +96,19 @@ async def test_hop_sensors( ), ( "sensor.next_billing_date", - "2020-11-03T00:00:00", + "2025-02-19T00:00:00", SensorDeviceClass.DATE, None, ), - ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ("sensor.hour_of_power_savings", "11.2", None, SensorStateClass.MEASUREMENT), ], ) async def test_account_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: AsyncMock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, device_class: str, @@ -119,7 +116,7 @@ async def test_account_sensors( ) -> None: """Test Account sensors for the Electric Kiwi integration.""" - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -133,9 +130,9 @@ async def test_account_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == state_class -async def test_check_and_move_time(ek_api: AsyncMock) -> None: +async def test_check_and_move_time(electrickiwi_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" - hop = await ek_api(Mock()).get_hop() + hop = await electrickiwi_api.get_hop() test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) dt_util.set_default_time_zone(TEST_TIMEZONE) From c222ffb4ecfae1946eb8e4ecd8b68f0eb46b8cf1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 5 Feb 2025 21:13:42 +0100 Subject: [PATCH 218/288] Bump holidays to 0.66 (#137449) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index edf3ebe7f04..6952d48ef32 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.65", "babel==2.15.0"] + "requirements": ["holidays==0.66", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4b9d072f747..cbb11a06aec 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.65"] + "requirements": ["holidays==0.66"] } diff --git a/requirements_all.txt b/requirements_all.txt index 284afccb5f1..83b6786e1c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend home-assistant-frontend==20250205.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07ddbd78fb9..25eb1978a2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend home-assistant-frontend==20250205.0 From ab807702521c30a66c0120ccb16687efd1e05b25 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:57:15 +0100 Subject: [PATCH 219/288] Explicitly pass in the config_entry in screenlogic coordinator init (#137463) explicitly pass in the config_entry in coordinator init --- homeassistant/components/screenlogic/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index a90c9cb2cf4..b3c438dc641 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -52,6 +52,8 @@ async def async_get_connect_info( class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -60,7 +62,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): gateway: ScreenLogicGateway, ) -> None: """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry self.gateway = gateway interval = timedelta( @@ -69,6 +70,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, # Debounced option since the device takes @@ -91,7 +93,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None try: if not self.gateway.is_connected: connect_info = await async_get_connect_info( From 02482529067ac59e76617cca9895164679f3d955 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:57:41 +0100 Subject: [PATCH 220/288] Explicitly pass in the config_entry in omnilogic coordinator init (#137466) explicitly pass in the config_entry in coordinator init --- homeassistant/components/omnilogic/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py index 72d16f03328..24c8cdf2554 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__) class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): """Class to manage fetching update data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -28,11 +30,11 @@ class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any ) -> None: """Initialize the global Omnilogic data updater.""" self.api = api - self.config_entry = config_entry super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name=name, update_interval=timedelta(seconds=polling_interval), ) From 6d13aa3741d052bb247a5fe118851d7372e3aa26 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:58:01 +0100 Subject: [PATCH 221/288] Explicitly pass in the config_entry in picnic coordinator init (#137465) explicitly pass in the config_entry in coordinator init --- homeassistant/components/picnic/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index b3979580990..de686cad37d 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -21,6 +21,8 @@ from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT class PicnicUpdateCoordinator(DataUpdateCoordinator): """The coordinator to fetch data from the Picnic API at a set interval.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -29,13 +31,13 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): ) -> None: """Initialize the coordinator with the given Picnic API client.""" self.picnic_api_client = picnic_api_client - self.config_entry = config_entry self._user_address = None logger = logging.getLogger(__name__) super().__init__( hass, logger, + config_entry=config_entry, name="Picnic coordinator", update_interval=timedelta(minutes=30), ) From 12095df4fadbd506a78fdafc112dec7b5920320f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:59:26 +0100 Subject: [PATCH 222/288] Explicitly pass in the config_entry in webmin coordinator init (#137462) explicitly pass in the config_entry in coordinator init --- homeassistant/components/webmin/coordinator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py index 45261787e75..261139faf10 100644 --- a/homeassistant/components/webmin/coordinator.py +++ b/homeassistant/components/webmin/coordinator.py @@ -22,6 +22,7 @@ from .helpers import get_instance_from_options, get_sorted_mac_addresses class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The Webmin data update coordinator.""" + config_entry: ConfigEntry mac_address: str unique_id: str @@ -29,7 +30,11 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize the Webmin data update coordinator.""" super().__init__( - hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self.instance, base_url = get_instance_from_options(hass, config_entry.options) @@ -53,7 +58,6 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): (DOMAIN, format_mac(mac_address)) for mac_address in mac_addresses } else: - assert self.config_entry self.unique_id = self.config_entry.entry_id async def _async_update_data(self) -> dict[str, Any]: From db6bd6aad1d5dac3af22e487d8a65d18bbc168e4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 21:59:50 +0100 Subject: [PATCH 223/288] Explicitly pass in the config_entry in Bluesound coordinator init (#137461) * explicitly pass in the config_entry in coordinator init * remove unneccessary assert --- .../components/bluesound/__init__.py | 24 ++++++------------- .../components/bluesound/coordinator.py | 24 ++++++++++++++++--- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 6cf1957f799..37e83ce2c47 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -1,8 +1,6 @@ """The bluesound component.""" -from dataclasses import dataclass - -from pyblu import Player, SyncStatus +from pyblu import Player from pyblu.errors import PlayerUnreachableError from homeassistant.config_entries import ConfigEntry @@ -14,7 +12,11 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import BluesoundCoordinator +from .coordinator import ( + BluesoundConfigEntry, + BluesoundCoordinator, + BluesoundRuntimeData, +) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -23,18 +25,6 @@ PLATFORMS = [ ] -@dataclass -class BluesoundRuntimeData: - """Bluesound data class.""" - - player: Player - sync_status: SyncStatus - coordinator: BluesoundCoordinator - - -type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Bluesound.""" return True @@ -53,7 +43,7 @@ async def async_setup_entry( except PlayerUnreachableError as ex: raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex - coordinator = BluesoundCoordinator(hass, player, sync_status) + coordinator = BluesoundCoordinator(hass, config_entry, player, sync_status) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py index e62f3ef96cf..ceaf0b392eb 100644 --- a/homeassistant/components/bluesound/coordinator.py +++ b/homeassistant/components/bluesound/coordinator.py @@ -12,6 +12,7 @@ import logging from pyblu import Input, Player, Preset, Status, SyncStatus from pyblu.errors import PlayerUnreachableError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -21,6 +22,15 @@ NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3) PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) +@dataclass +class BluesoundRuntimeData: + """Bluesound data class.""" + + player: Player + sync_status: SyncStatus + coordinator: BluesoundCoordinator + + @dataclass class BluesoundData: """Define a class to hold Bluesound data.""" @@ -31,6 +41,9 @@ class BluesoundData: inputs: list[Input] +type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] + + def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: """Cancel a task.""" @@ -45,8 +58,14 @@ def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]] class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): """Define an object to hold Bluesound data.""" + config_entry: BluesoundConfigEntry + def __init__( - self, hass: HomeAssistant, player: Player, sync_status: SyncStatus + self, + hass: HomeAssistant, + config_entry: BluesoundConfigEntry, + player: Player, + sync_status: SyncStatus, ) -> None: """Initialize.""" self.player = player @@ -55,12 +74,11 @@ class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=sync_status.name, ) async def _async_setup(self) -> None: - assert self.config_entry is not None - preset = await self.player.presets() inputs = await self.player.inputs() status = await self.player.status() From 2aea078d9a4eef829dbd464bbced0701826690f0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:00:50 +0100 Subject: [PATCH 224/288] Explicitly pass in the config_entry in rympro coordinator init (#137464) explicitly pass in the config_entry in coordinator init --- homeassistant/components/rympro/__init__.py | 2 +- homeassistant/components/rympro/coordinator.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index f24735f4ed0..20d208cca69 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data={**data, CONF_TOKEN: token}, ) - coordinator = RymProDataUpdateCoordinator(hass, rympro) + coordinator = RymProDataUpdateCoordinator(hass, entry, rympro) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 19f16005578..55e5f0f90df 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -7,6 +7,7 @@ import logging from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,13 +21,18 @@ _LOGGER = logging.getLogger(__name__) class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): """Class to manage fetching RYM Pro data.""" - def __init__(self, hass: HomeAssistant, rympro: RymPro) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, rympro: RymPro + ) -> None: """Initialize global RymPro data updater.""" self.rympro = rympro interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, ) @@ -40,7 +46,6 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): meter_id ) except UnauthorizedError as error: - assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) raise UpdateFailed(error) from error except (CannotConnectError, OperationError) as error: From b0a82a99130c2a51eb2ad26ea2e71e0439bdfb58 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 22:11:17 +0100 Subject: [PATCH 225/288] Explicitly pass in the config_entry in airgradient coordinator init (#137469) --- homeassistant/components/airgradient/__init__.py | 8 ++------ homeassistant/components/airgradient/coordinator.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index 3b27d6cda5e..8f7fd86847d 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from airgradient import AirGradientClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import AirGradientCoordinator +from .coordinator import AirGradientConfigEntry, AirGradientCoordinator PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -21,9 +20,6 @@ PLATFORMS: list[Platform] = [ ] -type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool: """Set up Airgradient from a config entry.""" @@ -31,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) entry.data[CONF_HOST], session=async_get_clientsession(hass) ) - coordinator = AirGradientCoordinator(hass, client) + coordinator = AirGradientCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index d2fc2a9de1b..7484c7e85a9 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -4,18 +4,17 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING from airgradient import AirGradientClient, AirGradientError, Config, Measures +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import AirGradientConfigEntry +type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] @dataclass @@ -32,11 +31,17 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): config_entry: AirGradientConfigEntry _current_version: str - def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: AirGradientConfigEntry, + client: AirGradientClient, + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=f"AirGradient {client.host}", update_interval=timedelta(minutes=1), ) From bf0080cbb068f897a479cd711edb04c19f272945 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 5 Feb 2025 23:09:54 +0100 Subject: [PATCH 226/288] Explicitly pass in the config_entry in supervisor coordinator init (#137472) --- homeassistant/components/hassio/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 2d39e740e63..833068a713c 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -295,6 +295,8 @@ def async_remove_addons_from_dev_reg( class HassioDataUpdateCoordinator(DataUpdateCoordinator): """Class to retrieve Hass.io status.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry ) -> None: @@ -302,6 +304,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, # We don't want an immediate refresh since we want to avoid From 2946fbad00fd7426a3a135da83962e4b1aaa1839 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 5 Feb 2025 23:15:18 +0100 Subject: [PATCH 227/288] Bump aioairq version to 0.4.4 (#137454) --- homeassistant/components/airq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 1ae7da14875..d4a6e9c295f 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.4.3"] + "requirements": ["aioairq==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 83b6786e1c8..5c8bf3923c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25eb1978a2a..38d21a52091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 From d1b22312ac3edce79f2e0d9afeba7f6b925357ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 21:04:52 -0600 Subject: [PATCH 228/288] Bump aiohttp-asyncmdnsresolver to 0.1.0 (#137492) changelog: https://github.com/aio-libs/aiohttp-asyncmdnsresolver/compare/v0.0.3...v0.1.0 Switches to the new AsyncDualMDNSResolver class to which tries via mDNS and DNS for .local domains since we have so many different user DNS configurations to support fixes #137479 fixes #136922 --- homeassistant/helpers/aiohttp_client.py | 6 +++--- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b5f5ee9a961..3d8dc247857 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -15,7 +15,7 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout -from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver +from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver from homeassistant import config_entries from homeassistant.components import zeroconf @@ -377,5 +377,5 @@ def _async_get_connector( @callback -def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver: - return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) +def _async_make_resolver(hass: HomeAssistant) -> AsyncDualMDNSResolver: + return AsyncDualMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 46bdc2b9f68..f153942b991 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.2.2 aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp-asyncmdnsresolver==0.0.3 +aiohttp-asyncmdnsresolver==0.1.0 aiohttp-fast-zlib==0.2.2 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 8ddf46d8be9..a49e083c0bc 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.2", - "aiohttp-asyncmdnsresolver==0.0.3", + "aiohttp-asyncmdnsresolver==0.1.0", "aiozoneinfo==0.2.3", "astral==2.2", "async-interrupt==1.2.1", diff --git a/requirements.txt b/requirements.txt index d8d7b235390..106c37ba130 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.3.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 -aiohttp-asyncmdnsresolver==0.0.3 +aiohttp-asyncmdnsresolver==0.1.0 aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.1 From 39b46baeaa6215b506ad22c0b9beb1e1d94e9537 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 21:05:12 -0600 Subject: [PATCH 229/288] Bump aiohttp to 3.11.12 (#137494) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.11...v3.11.12 --- 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 f153942b991..cd768198541 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.0 aiohttp-fast-zlib==0.2.2 -aiohttp==3.11.11 +aiohttp==3.11.12 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index a49e083c0bc..ab927b21b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.11", + "aiohttp==3.11.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.2", "aiohttp-asyncmdnsresolver==0.1.0", diff --git a/requirements.txt b/requirements.txt index 106c37ba130..5e5fabb5723 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.11 +aiohttp==3.11.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 aiohttp-asyncmdnsresolver==0.1.0 From 3b871afcc45839a2f07999427a6c06e4933d3676 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 23:27:13 -0500 Subject: [PATCH 230/288] Update default Google model to Gemini Flash 2.0 (#137505) --- .../google_generative_ai_conversation/const.py | 2 +- .../snapshots/test_conversation.ambr | 12 ++++++------ .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_init.ambr | 4 ++-- .../test_config_flow.py | 8 +++++++- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index bd60e8d94c1..4d83b935528 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -8,7 +8,7 @@ CONF_PROMPT = "prompt" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest" +RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 21458abb7c8..c89981e67bb 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -244,7 +244,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -296,7 +296,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -348,7 +348,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -401,7 +401,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -454,7 +454,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -565,7 +565,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 316bf74b72a..b445499ad49 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-1.5-flash-latest', + 'chat_model': 'models/gemini-2.0-flash', 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f68f4c6bf14..c9e02a6d009 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', }), ), tuple( @@ -32,7 +32,7 @@ tuple( ), dict({ - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', }), ), tuple( diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index d4992c732e1..ee5291196c3 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -39,6 +39,12 @@ from tests.common import MockConfigEntry @pytest.fixture def mock_models(): """Mock the model list API.""" + model_20_flash = Mock( + display_name="Gemini 2.0 Flash", + supported_generation_methods=["generateContent"], + ) + model_20_flash.name = "models/gemini-2.0-flash" + model_15_flash = Mock( display_name="Gemini 1.5 Flash", supported_generation_methods=["generateContent"], @@ -58,7 +64,7 @@ def mock_models(): model_10_pro.name = "models/gemini-pro" with patch( "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", - return_value=iter([model_15_flash, model_15_pro, model_10_pro]), + return_value=iter([model_20_flash, model_15_flash, model_15_pro, model_10_pro]), ): yield From 283b0908c86db9f4b687706bf0f1c37a9879bf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 6 Feb 2025 07:32:46 +0100 Subject: [PATCH 231/288] Move cloud backup upload/download handlers to lib (#137416) * Move cloud backup upload/download handlers to lib * Update backup.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/cloud/backup.py | 77 ++--------- tests/components/cloud/conftest.py | 2 + tests/components/cloud/test_backup.py | 166 ++++------------------- 3 files changed, 39 insertions(+), 206 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index d42e846259c..f6d24656ccb 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,16 +8,11 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any +from typing import Any, Literal -from aiohttp import ClientError, ClientTimeout +from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.cloud_api import ( - async_files_delete_file, - async_files_download_details, - async_files_list, - async_files_upload_details, -) +from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -28,7 +23,7 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP = "backup" +_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -109,63 +104,14 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Backup not found") try: - details = await async_files_download_details( - self._cloud, + content = await self._cloud.files.download( storage_type=_STORAGE_BACKUP, filename=self._get_backup_filename(), ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get download details") from err + except CloudError as err: + raise BackupAgentError(f"Failed to download backup: {err}") from err - try: - resp = await self._cloud.websession.get( - details["url"], - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - - resp.raise_for_status() - except ClientError as err: - raise BackupAgentError("Failed to download backup") from err - - return ChunkAsyncStreamIterator(resp.content) - - async def _async_do_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - filename: str, - base64md5hash: str, - metadata: dict[str, Any], - size: int, - ) -> None: - """Upload a backup.""" - try: - details = await async_files_upload_details( - self._cloud, - storage_type=_STORAGE_BACKUP, - filename=filename, - metadata=metadata, - size=size, - base64md5hash=base64md5hash, - ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get upload details") from err - - try: - upload_status = await self._cloud.websession.put( - details["url"], - data=await open_stream(), - headers=details["headers"] | {"content-length": str(size)}, - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - _LOGGER.log( - logging.DEBUG if upload_status.status < 400 else logging.WARNING, - "Backup upload status: %s", - upload_status.status, - ) - upload_status.raise_for_status() - except (TimeoutError, ClientError) as err: - raise BackupAgentError("Failed to upload backup") from err + return ChunkAsyncStreamIterator(content) async def async_upload_backup( self, @@ -190,7 +136,8 @@ class CloudBackupAgent(BackupAgent): tries = 1 while tries <= _RETRY_LIMIT: try: - await self._async_do_upload_backup( + await self._cloud.files.upload( + storage_type=_STORAGE_BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -198,9 +145,9 @@ class CloudBackupAgent(BackupAgent): size=size, ) break - except BackupAgentError as err: + except CloudError as err: if tries == _RETRY_LIMIT: - raise + raise BackupAgentError("Failed to upload backup") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 7002f7c39ec..276a06a7f46 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -9,6 +9,7 @@ from hass_nabucasa import Cloud from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.files import Files from hass_nabucasa.google_report_state import GoogleReportState from hass_nabucasa.ice_servers import IceServers from hass_nabucasa.iot import CloudIoT @@ -68,6 +69,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED ) mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None mock_cloud.ice_servers = MagicMock( spec=IceServers, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5b2b8751311..ba789e093ff 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -1,14 +1,14 @@ """Test the cloud backup platform.""" -from collections.abc import AsyncGenerator, AsyncIterator, Generator +from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.files import FilesError import pytest -from yarl import URL from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -22,11 +22,20 @@ from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -55,49 +64,6 @@ def mock_delete_file() -> Generator[MagicMock]: yield delete_file -@pytest.fixture -def mock_get_download_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_download_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" - ), - } - yield download_details - - -@pytest.fixture -def mock_get_upload_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_upload_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" - ), - "headers": { - "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", - "x-amz-meta-storage-type": "backup", - "x-amz-meta-b64json": ( - "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" - "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" - "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" - "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" - "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" - ), - }, - } - yield download_details - - @pytest.fixture def mock_list_files() -> Generator[MagicMock]: """Mock list files.""" @@ -264,52 +230,30 @@ async def test_agents_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get( - mock_get_download_details.return_value["url"], content=b"backup data" - ) + cloud.files.download.return_value = MockStreamReaderChunked(b"backup data") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_download_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_get_download_details: Mock, - side_effect: Exception, -) -> None: - """Test agent download backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "23e64aec" - mock_get_download_details.side_effect = side_effect - - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") - assert resp.status == 500 - content = await resp.content.read() - assert "Failed to get download details" in content.decode() - - @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_fail_get( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup, when cloud user is logged in.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + cloud.files.download.side_effect = FilesError("Oh no :(") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 500 @@ -336,8 +280,7 @@ async def test_agents_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, + cloud: Mock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -355,8 +298,6 @@ async def test_agents_upload( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"]) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -374,26 +315,22 @@ async def test_agents_upload( data={"file": StringIO("test")}, ) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][0] == "PUT" - assert aioclient_mock.mock_calls[-1][1] == URL( - mock_get_upload_details.return_value["url"] - ) - assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + assert len(cloud.files.upload.mock_calls) == 1 + metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] + assert metadata["backup_id"] == backup_id assert resp.status == 201 assert f"Uploading backup {backup_id}" in caplog.text -@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.parametrize("side_effect", [FilesError("Boom!"), CloudError("Boom!")]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_upload_fail_put( +async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, - put_mock_kwargs: dict[str, Any], + side_effect: Exception, + cloud: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" @@ -412,7 +349,8 @@ async def test_agents_upload_fail_put( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + cloud.files.upload.side_effect = side_effect with ( patch( @@ -435,7 +373,6 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() - assert len(aioclient_mock.mock_calls) == 2 assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] @@ -445,59 +382,6 @@ async def test_agents_upload_fail_put( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in") -async def test_agents_upload_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_storage: dict[str, Any], - mock_get_upload_details: Mock, - side_effect: Exception, -) -> None: - """Test agent upload backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "test-backup" - mock_get_upload_details.side_effect = side_effect - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=0, - ) - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.cloud.backup.asyncio.sleep"), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, - ) - await hass.async_block_till_done() - - assert resp.status == 201 - store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] - assert len(store_backups) == 1 - stored_backup = store_backups[0] - assert stored_backup["backup_id"] == backup_id - assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] - - async def test_agents_upload_not_protected( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 12b36658721b813400eca15d0f74cf8de10b1f9f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Feb 2025 08:30:41 +0100 Subject: [PATCH 232/288] Bump bring-api version to 1.0.1 (#137496) --- homeassistant/components/bring/coordinator.py | 24 +++++++------------ homeassistant/components/bring/entity.py | 2 +- homeassistant/components/bring/manifest.json | 2 +- homeassistant/components/bring/sensor.py | 3 +-- homeassistant/components/bring/todo.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bring/conftest.py | 2 +- tests/components/bring/test_config_flow.py | 6 +---- tests/components/bring/test_init.py | 4 +--- 10 files changed, 18 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 9473d0614e3..5c5e1567e7e 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -9,10 +9,12 @@ import logging from bring_api import ( Bring, BringAuthException, + BringItemsResponse, + BringList, BringParseException, BringRequestException, + BringUserSettingsResponse, ) -from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse from mashumaro.mixins.orjson import DataClassORJSONMixin from homeassistant.config_entries import ConfigEntry @@ -62,20 +64,12 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e - except BringAuthException: - # try to recover by refreshing access token, otherwise - # initiate reauth flow - try: - await self.bring.retrieve_new_access_token() - except (BringRequestException, BringParseException) as exc: - raise UpdateFailed("Refreshing authentication token failed") from exc - except BringAuthException as exc: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_EMAIL: self.bring.mail}, - ) from exc - return self.data + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.bring.mail}, + ) from e if self.previous_lists - ( current_lists := {lst.listUuid for lst in self.lists} diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 3de0140d82c..ee90f22beef 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from bring_api.types import BringList +from bring_api import BringList from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index ecd3e911078..16767b7b0d6 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], - "requirements": ["bring-api==1.0.0"] + "requirements": ["bring-api==1.0.1"] } diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 651307a2eee..6c1e8fe9600 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -6,9 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from bring_api import BringUserSettingsResponse +from bring_api import BringList, BringUserSettingsResponse from bring_api.const import BRING_SUPPORTED_LOCALES -from bring_api.types import BringList from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index ad4de4196c1..9b17deb55a8 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -9,10 +9,10 @@ import uuid from bring_api import ( BringItem, BringItemOperation, + BringList, BringNotificationType, BringRequestException, ) -from bring_api.types import BringList import voluptuous as vol from homeassistant.components.todo import ( diff --git a/requirements_all.txt b/requirements_all.txt index 5c8bf3923c7..77c9daae6ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -650,7 +650,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.0 +bring-api==1.0.1 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38d21a52091..b0dab1b2e91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -570,7 +570,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.0 +bring-api==1.0.1 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 2b2e9257097..34a122fca47 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch import uuid -from bring_api.types import ( +from bring_api import ( BringAuthResponse, BringItemsResponse, BringListResponse, diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 93e86051a75..306f63525d1 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -2,11 +2,7 @@ from unittest.mock import AsyncMock -from bring_api.exceptions import ( - BringAuthException, - BringParseException, - BringRequestException, -) +from bring_api import BringAuthException, BringParseException, BringRequestException import pytest from homeassistant.components.bring.const import DOMAIN diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index a77c709315f..f053f294ef1 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -142,7 +142,6 @@ async def test_config_entry_not_ready_udpdate_failed( @pytest.mark.parametrize( ("exception", "state"), [ - (None, ConfigEntryState.LOADED), (BringAuthException, ConfigEntryState.SETUP_ERROR), (BringRequestException, ConfigEntryState.SETUP_RETRY), (BringParseException, ConfigEntryState.SETUP_RETRY), @@ -159,9 +158,8 @@ async def test_config_entry_not_ready_auth_error( mock_bring_client.load_lists.side_effect = [ mock_bring_client.load_lists.return_value, - BringAuthException, + exception, ] - mock_bring_client.retrieve_new_access_token.side_effect = exception bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) From c0061dba77997a12a0be216c499ba4837f522e25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Feb 2025 01:31:23 -0600 Subject: [PATCH 233/288] Bump govee-ble to 0.43.0 to fix compat with new H5179 firmware (#137508) changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.42.1...v0.43.0 fixes #136969 --- homeassistant/components/govee_ble/manifest.json | 6 +++++- homeassistant/generated/bluetooth.py | 5 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 4d871a991a6..1c61ae31010 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -38,6 +38,10 @@ "local_name": "GV5126*", "connectable": false }, + { + "local_name": "GV5179*", + "connectable": false + }, { "local_name": "GVH5127*", "connectable": false @@ -131,5 +135,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.42.1"] + "requirements": ["govee-ble==0.43.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 8a5880dcde9..447b6d284f0 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -187,6 +187,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GV5126*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GV5179*", + }, { "connectable": False, "domain": "govee_ble", diff --git a/requirements_all.txt b/requirements_all.txt index 77c9daae6ac..b4c3918eb85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1052,7 +1052,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.1 +govee-ble==0.43.0 # homeassistant.components.govee_light_local govee-local-api==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0dab1b2e91..0dc2c6d41f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -902,7 +902,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.1 +govee-ble==0.43.0 # homeassistant.components.govee_light_local govee-local-api==2.0.0 From c4454ad5eaae1b47d47ffbef5ad94ea7ebe1fe60 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Feb 2025 08:32:56 +0100 Subject: [PATCH 234/288] Bump habiticalib to v0.3.5 (#137510) --- .../components/habitica/manifest.json | 2 +- homeassistant/components/habitica/services.py | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../habitica/snapshots/test_diagnostics.ambr | 8 +- .../habitica/snapshots/test_services.ambr | 1820 ++++++++--------- 6 files changed, 919 insertions(+), 918 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 6ace6d45509..9ea346a0dcb 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.4"] + "requirements": ["habiticalib==0.3.5"] } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index a28aada85fa..ed4a6444ea2 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -510,7 +510,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 or (task.notes and keyword in task.notes.lower()) or any(keyword in item.text.lower() for item in task.checklist) ] - result: dict[str, Any] = {"tasks": response} + result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]} + return result hass.services.async_register( diff --git a/requirements_all.txt b/requirements_all.txt index b4c3918eb85..877030db00a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.4 +habiticalib==0.3.5 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0dc2c6d41f7..b0867a965f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.4 +habiticalib==0.3.5 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 1f3a14fade1..2fe3513a646 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ 'habitica_data': dict({ 'tasks': list([ dict({ - 'Type': 'habit', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -71,6 +70,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'habit', 'up': True, 'updatedAt': '2024-10-10T15:57:14.287000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -80,7 +80,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'todo', 'alias': None, 'attribute': 'str', 'byHabitica': True, @@ -143,6 +142,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'todo', 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -152,7 +152,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'reward', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -215,6 +214,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'reward', 'up': None, 'updatedAt': '2024-10-10T15:57:14.290000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -224,7 +224,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'daily', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -341,6 +340,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'daily', 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index f40d50ded98..e25ed8db313 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -3,9 +3,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -20,13 +19,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -44,12 +43,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -66,18 +65,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -92,13 +91,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -117,19 +116,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -146,18 +145,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -172,13 +171,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -196,12 +195,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -218,18 +217,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -244,13 +243,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -269,19 +268,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -298,18 +297,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -321,7 +320,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -329,13 +328,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -354,7 +353,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -362,7 +361,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -370,7 +369,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -378,7 +377,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -386,7 +385,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -394,7 +393,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -402,7 +401,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -410,7 +409,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -418,7 +417,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -426,7 +425,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -434,25 +433,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -464,23 +463,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -495,13 +494,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -520,7 +519,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -528,7 +527,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -536,7 +535,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -544,7 +543,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -552,7 +551,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -560,7 +559,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -568,7 +567,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -576,7 +575,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -584,7 +583,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -592,30 +591,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -627,23 +626,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -655,7 +654,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -663,13 +662,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -687,18 +686,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -710,24 +709,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -742,8 +741,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -766,12 +765,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -786,22 +785,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -816,8 +815,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -840,17 +839,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -867,18 +866,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -893,7 +892,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -917,12 +916,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -939,18 +938,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -965,8 +964,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -989,12 +988,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1009,21 +1008,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1038,7 +1037,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1062,12 +1061,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1084,18 +1083,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1110,13 +1109,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1134,18 +1133,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1157,14 +1156,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -1172,9 +1172,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1189,13 +1188,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1213,18 +1212,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1236,23 +1235,23 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1267,7 +1266,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1291,12 +1290,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -1311,21 +1310,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1340,7 +1339,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1364,12 +1363,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1384,12 +1383,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -1402,9 +1402,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1416,7 +1415,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1424,13 +1423,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1449,7 +1448,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1457,7 +1456,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1465,7 +1464,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1473,7 +1472,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1481,7 +1480,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1489,7 +1488,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1497,7 +1496,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1505,7 +1504,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1513,7 +1512,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1521,7 +1520,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1529,25 +1528,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1559,14 +1558,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -1579,9 +1579,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1596,13 +1595,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1620,12 +1619,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1642,18 +1641,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1668,13 +1667,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1693,19 +1692,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1722,9 +1721,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1737,9 +1737,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1751,7 +1750,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1759,13 +1758,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1783,18 +1782,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1806,24 +1805,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1838,8 +1837,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -1862,12 +1861,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1882,13 +1881,14 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1901,9 +1901,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1918,13 +1917,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1943,7 +1942,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1951,7 +1950,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1959,7 +1958,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1967,7 +1966,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1975,7 +1974,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1983,7 +1982,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1991,7 +1990,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1999,7 +1998,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2007,7 +2006,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2015,30 +2014,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2050,14 +2049,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), @@ -2070,9 +2070,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2087,13 +2086,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2112,19 +2111,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2141,18 +2140,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2164,7 +2163,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2172,13 +2171,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2197,7 +2196,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2205,7 +2204,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2213,7 +2212,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2221,7 +2220,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2229,7 +2228,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2237,7 +2236,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2245,7 +2244,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2253,7 +2252,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2261,7 +2260,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2269,7 +2268,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2277,25 +2276,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2307,14 +2306,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -2327,9 +2327,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2344,13 +2343,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2368,12 +2367,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2390,18 +2389,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2416,13 +2415,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2441,19 +2440,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2470,18 +2469,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2496,13 +2495,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2520,12 +2519,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2542,18 +2541,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2568,13 +2567,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2593,19 +2592,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2622,18 +2621,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2645,7 +2644,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2653,13 +2652,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2678,7 +2677,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2686,7 +2685,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2694,7 +2693,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2702,7 +2701,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2710,7 +2709,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2718,7 +2717,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2726,7 +2725,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2734,7 +2733,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2742,7 +2741,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2750,7 +2749,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2758,25 +2757,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2788,23 +2787,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2819,13 +2818,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2844,7 +2843,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2852,7 +2851,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2860,7 +2859,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2868,7 +2867,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2876,7 +2875,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2884,7 +2883,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2892,7 +2891,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2900,7 +2899,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2908,7 +2907,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2916,30 +2915,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2951,23 +2950,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2982,8 +2981,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3006,12 +3005,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3026,22 +3025,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3056,8 +3055,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3080,17 +3079,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -3107,18 +3106,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3133,7 +3132,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3157,12 +3156,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3179,18 +3178,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3205,8 +3204,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3229,12 +3228,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3249,21 +3248,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3278,7 +3277,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3302,12 +3301,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3324,18 +3323,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3350,13 +3349,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3374,18 +3373,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3397,14 +3396,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -3412,9 +3412,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3429,13 +3428,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3453,18 +3452,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3476,14 +3475,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -3502,9 +3502,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3516,7 +3515,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -3524,13 +3523,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3548,18 +3547,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3571,24 +3570,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3603,7 +3602,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3627,12 +3626,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3647,12 +3646,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3665,9 +3665,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3682,7 +3681,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3706,12 +3705,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -3726,12 +3725,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3744,9 +3744,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3761,13 +3760,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3785,12 +3784,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3807,18 +3806,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3833,13 +3832,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3858,19 +3857,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3887,18 +3886,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3913,13 +3912,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3937,12 +3936,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3959,18 +3958,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3985,13 +3984,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4010,19 +4009,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4039,18 +4038,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4062,7 +4061,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4070,13 +4069,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4095,7 +4094,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4103,7 +4102,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4111,7 +4110,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4119,7 +4118,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4127,7 +4126,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4135,7 +4134,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4143,7 +4142,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4151,7 +4150,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4159,7 +4158,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4167,7 +4166,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4175,25 +4174,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4205,23 +4204,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4236,13 +4235,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4261,7 +4260,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4269,7 +4268,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4277,7 +4276,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4285,7 +4284,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4293,7 +4292,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4301,7 +4300,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4309,7 +4308,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4317,7 +4316,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4325,7 +4324,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4333,30 +4332,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4368,23 +4367,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4396,7 +4395,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4404,13 +4403,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4428,18 +4427,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -4451,24 +4450,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4483,13 +4482,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4507,18 +4506,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4530,14 +4529,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -4545,9 +4545,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4562,13 +4561,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4586,18 +4585,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4609,14 +4608,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -4629,9 +4629,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4643,7 +4642,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4651,13 +4650,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4676,7 +4675,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4684,7 +4683,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4692,7 +4691,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4700,7 +4699,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4708,7 +4707,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4716,7 +4715,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4724,7 +4723,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4732,7 +4731,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4740,7 +4739,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4748,7 +4747,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4756,25 +4755,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4786,23 +4785,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4817,13 +4816,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4842,7 +4841,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4850,7 +4849,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4858,7 +4857,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4866,7 +4865,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4874,7 +4873,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4882,7 +4881,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4890,7 +4889,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4898,7 +4897,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4906,7 +4905,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4914,30 +4913,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4949,23 +4948,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4977,7 +4976,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4985,13 +4984,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5009,18 +5008,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -5032,24 +5031,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5064,13 +5063,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5088,18 +5087,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5111,14 +5110,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -5126,9 +5126,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5143,13 +5142,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5167,18 +5166,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5190,14 +5189,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -5210,9 +5210,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5227,13 +5226,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5251,12 +5250,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5273,18 +5272,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5299,13 +5298,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5324,19 +5323,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5353,18 +5352,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5379,13 +5378,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5403,12 +5402,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5425,18 +5424,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5451,13 +5450,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5476,19 +5475,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5505,9 +5504,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -5520,9 +5520,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5537,7 +5536,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5561,12 +5560,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5583,9 +5582,10 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), @@ -5598,9 +5598,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5615,8 +5614,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5639,12 +5638,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5659,22 +5658,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5689,8 +5688,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5713,17 +5712,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -5740,18 +5739,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5766,7 +5765,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5790,12 +5789,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5812,18 +5811,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5838,8 +5837,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5862,12 +5861,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5882,21 +5881,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5911,7 +5910,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5935,12 +5934,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5957,18 +5956,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5983,7 +5982,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6007,12 +6006,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6027,21 +6026,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6056,7 +6055,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6080,12 +6079,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6100,12 +6099,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -6118,9 +6118,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6135,8 +6134,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6159,12 +6158,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6179,22 +6178,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6209,8 +6208,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6233,17 +6232,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -6260,18 +6259,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6286,7 +6285,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6310,12 +6309,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6332,18 +6331,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6358,8 +6357,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6382,12 +6381,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6402,21 +6401,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6431,7 +6430,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6455,12 +6454,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6475,21 +6474,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6504,7 +6503,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6528,12 +6527,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6548,12 +6547,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), From e93451a19503fe23215917d14cfbdb9d5653d3a8 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 6 Feb 2025 02:38:21 -0500 Subject: [PATCH 235/288] Deprecate Linear Garage Door integration (#137502) --- .../components/linear_garage_door/__init__.py | 25 ++++++++++++++++++- .../linear_garage_door/strings.json | 6 +++++ .../linear_garage_door/test_init.py | 22 ++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index 5d987a24b2a..e4aa30c98df 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from .const import DOMAIN from .coordinator import LinearUpdateCoordinator @@ -15,6 +16,21 @@ PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Linear Garage Door from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + breaks_in_ha_version="2025.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_integration", + translation_placeholders={ + "nice_go": "https://www.home-assistant.io/integrations/linear_garage_door", + "entries": "/config/integrations/integration/linear_garage_door", + }, + ) + coordinator = LinearUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() @@ -27,6 +43,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json index 23624b4acfd..40ffcf22e8d 100644 --- a/homeassistant/components/linear_garage_door/strings.json +++ b/homeassistant/components/linear_garage_door/strings.json @@ -23,5 +23,11 @@ "name": "[%key:component::light::title%]" } } + }, + "issues": { + "deprecated_integration": { + "title": "The Linear Garage Door integration will be removed", + "description": "The Linear Garage Door integration will be removed as it has been replaced by the [Nice G.O.]({nice_go}) integration. Please migrate to the new integration.\n\nTo resolve this issue, please remove all Linear Garage Door entries from your configuration and add the new Nice G.O. integration. [Click here to see your existing Linear Garage Door integration entries]({entries})." + } } } diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py index 640264eb207..8f1e85f28ff 100644 --- a/tests/components/linear_garage_door/test_init.py +++ b/tests/components/linear_garage_door/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from linear_garage_door import InvalidLoginError import pytest +from homeassistant.components.linear_garage_door.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from . import setup_integration @@ -51,3 +53,23 @@ async def test_setup_failure( await setup_integration(hass, mock_config_entry, []) assert mock_config_entry.state == entry_state + + +async def test_repair_issue( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test reauth trigger setup.""" + + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None From 836ad8543b4ea365052ed17aa75921c98451a3f9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Feb 2025 09:52:13 +0100 Subject: [PATCH 236/288] Fix typo in keba and replace key references with UI-friendly descriptions (#137527) --- homeassistant/components/keba/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json index ed8594a1068..49ce01f4332 100644 --- a/homeassistant/components/keba/strings.json +++ b/homeassistant/components/keba/strings.json @@ -2,7 +2,7 @@ "services": { "request_data": { "name": "Request data", - "description": "Requesta new data from the charging station." + "description": "Requests new data from the charging station." }, "authorize": { "name": "Authorize", @@ -46,7 +46,7 @@ "fields": { "failsafe_timeout": { "name": "Failsafe timeout", - "description": "Timeout after which the failsafe mode is triggered, if set_current was not executed during this time." + "description": "Timeout after which the failsafe mode is triggered if the 'Set current' action was not run during this time." }, "failsafe_fallback": { "name": "Failsafe fallback", @@ -54,7 +54,7 @@ }, "failsafe_persist": { "name": "Failsafe persist", - "description": "If failsafe_persist is 0, the failsafe option is only until charging station reboot. If failsafe_persist is 1, the failsafe option will survive a reboot." + "description": "If set to 0, the failsafe option will be disabled after a charging station reboot. If set to 1, the failsafe option will survive a reboot." } } } From 8654597e2578cbe1c6ecaf25aa63a5e188cef112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 6 Feb 2025 09:57:10 +0100 Subject: [PATCH 237/288] Handle non-retryable errors when uploading cloud backup (#137517) --- homeassistant/components/cloud/backup.py | 13 +++- homeassistant/components/cloud/strings.json | 5 ++ tests/components/cloud/test_backup.py | 72 +++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index f6d24656ccb..9531604ccc7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -12,6 +12,7 @@ from typing import Any, Literal from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError +from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError @@ -145,9 +146,19 @@ class CloudBackupAgent(BackupAgent): size=size, ) break + except CloudApiNonRetryableError as err: + if err.code == "NC-SH-FH-03": + raise BackupAgentError( + translation_domain=DOMAIN, + translation_key="backup_size_too_large", + translation_placeholders={ + "size": str(round(size / (1024**3), 2)) + }, + ) from err + raise BackupAgentError(f"Failed to upload backup {err}") from err except CloudError as err: if tries == _RETRY_LIMIT: - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 1da91f67813..6380ee9c312 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -17,6 +17,11 @@ "subscription_expiration": "Subscription expiration" } }, + "exceptions": { + "backup_size_too_large": { + "message": "The backup size of {size}GB is too large to be uploaded to Home Assistant Cloud." + } + }, "issues": { "deprecated_gender": { "title": "The {deprecated_option} text-to-speech option is deprecated", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index ba789e093ff..6e59b7d983e 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.files import FilesError import pytest @@ -375,6 +376,77 @@ async def test_agents_upload_fail( assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 2 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.parametrize( + ("side_effect", "logmsg"), + [ + ( + CloudApiNonRetryableError("Boom!", code="NC-SH-FH-03"), + "The backup size of 13.37GB is too large to be uploaded to Home Assistant Cloud", + ), + ( + CloudApiNonRetryableError("Boom!", code="NC-CE-01"), + "Failed to upload backup Boom!", + ), + ], +) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_non_retryable( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + side_effect: Exception, + logmsg: str, + cloud: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent upload backup fails with non-retryable error.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=14358124749, + ) + + cloud.files.upload.side_effect = side_effect + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert logmsg in caplog.text + assert resp.status == 201 + assert cloud.files.upload.call_count == 1 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] From 4deffb233dbbb2128cff14f5fe82374cff094e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 6 Feb 2025 10:09:29 +0100 Subject: [PATCH 238/288] Fix Mill issue, where no sensors were shown (#137521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix mill issue #137477 Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/mill/climate.py | 4 +--- homeassistant/components/mill/entity.py | 4 ++-- homeassistant/components/mill/number.py | 6 +++--- homeassistant/components/mill/sensor.py | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 0df2fe9335e..3cd9247c63a 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -105,10 +105,8 @@ class MillHeater(MillBaseEntity, ClimateEntity): self, coordinator: MillDataUpdateCoordinator, device: mill.Heater ) -> None: """Initialize the thermostat.""" - - super().__init__(coordinator, device) self._attr_unique_id = device.device_id - self._update_attr(device) + super().__init__(coordinator, device) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/mill/entity.py b/homeassistant/components/mill/entity.py index f24dbeb2c26..06056aba336 100644 --- a/homeassistant/components/mill/entity.py +++ b/homeassistant/components/mill/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import abstractmethod -from mill import Heater, MillDevice +from mill import MillDevice from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -45,7 +45,7 @@ class MillBaseEntity(CoordinatorEntity[MillDataUpdateCoordinator]): @abstractmethod @callback - def _update_attr(self, device: MillDevice | Heater) -> None: + def _update_attr(self, device: MillDevice) -> None: """Update the attribute of the entity.""" @property diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index af27159caf0..b4ef7bdd2c2 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mill import MillDevice +from mill import Heater, MillDevice from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -27,6 +27,7 @@ async def async_setup_entry( async_add_entities( MillNumber(mill_data_coordinator, mill_device) for mill_device in mill_data_coordinator.data.values() + if isinstance(mill_device, Heater) ) @@ -45,9 +46,8 @@ class MillNumber(MillBaseEntity, NumberEntity): mill_device: MillDevice, ) -> None: """Initialize the number.""" - super().__init__(coordinator, mill_device) self._attr_unique_id = f"{mill_device.device_id}_max_heating_power" - self._update_attr(mill_device) + super().__init__(coordinator, mill_device) @callback def _update_attr(self, device: MillDevice) -> None: diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 018b9466deb..57eead9be18 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -192,9 +192,9 @@ class MillSensor(MillBaseEntity, SensorEntity): mill_device: mill.Socket | mill.Heater, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, mill_device) self.entity_description = entity_description self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" + super().__init__(coordinator, mill_device) @callback def _update_attr(self, device): From 9ca8af0a003e9c5d9fd95a6ee8a1a73e07dbe031 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Feb 2025 10:10:47 +0100 Subject: [PATCH 239/288] Add data_description to Bring! integration (#137513) --- homeassistant/components/bring/config_flow.py | 8 +++++++- homeassistant/components/bring/quality_scale.yaml | 2 +- homeassistant/components/bring/strings.json | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index bfb5a2cd50f..94f9e664a60 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -68,7 +68,13 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "google_play": "https://play.google.com/store/apps/details?id=ch.publisheria.bring", + "app_store": "https://itunes.apple.com/app/apple-store/id580669177", + }, ) async def async_step_reauth( diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 0b4191d5c61..53329ad637f 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -7,7 +7,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: todo + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: todo diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index ea9af03484e..70250849c79 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -5,9 +5,15 @@ "config": { "step": { "user": { + "title": "Bring! Grocery shopping list", + "description": "Connect your Bring! account to sync your shopping lists with Home Assistant.\n\nDon't have a Bring! account? Download the app on [Google Play for Android]({google_play}) or the [App Store for iOS]({app_store}) to sign up.", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address associated with your Bring! account.", + "password": "The password to login to your Bring! account." } }, "reauth_confirm": { @@ -16,6 +22,10 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::bring::config::step::user::data_description::email%]", + "password": "[%key:component::bring::config::step::user::data_description::email%]" } } }, From 84d7cb3a760ffe6b379781ed84ba6019f7f4f4c4 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:35:41 +0100 Subject: [PATCH 240/288] Enable strict-typing for Bring! integration (#137518) * Enable strict-typing for Bring! integration * Explicitly pass in the config_entry in coordinator init --- .strict-typing | 1 + homeassistant/components/bring/__init__.py | 9 +++------ homeassistant/components/bring/coordinator.py | 9 +++++++-- homeassistant/components/bring/diagnostics.py | 2 +- homeassistant/components/bring/quality_scale.yaml | 2 +- homeassistant/components/bring/sensor.py | 3 +-- homeassistant/components/bring/todo.py | 3 +-- mypy.ini | 10 ++++++++++ 8 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.strict-typing b/.strict-typing index 4cebcb6f445..1e3187980cc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -119,6 +119,7 @@ homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* homeassistant.components.braviatv.* +homeassistant.components.bring.* homeassistant.components.brother.* homeassistant.components.browser.* homeassistant.components.bryant_evolution.* diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 0ee8e3b3155..a4695e4f958 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -6,19 +6,16 @@ import logging from bring_api import Bring -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringConfigEntry, BringDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) -type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" @@ -26,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo session = async_get_clientsession(hass) bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) - coordinator = BringDataUpdateCoordinator(hass, bring) + coordinator = BringDataUpdateCoordinator(hass, entry, bring) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -36,6 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 5c5e1567e7e..23082180fe1 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -28,6 +28,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] + @dataclass(frozen=True) class BringData(DataClassORJSONMixin): @@ -40,15 +42,18 @@ class BringData(DataClassORJSONMixin): class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: BringConfigEntry user_settings: BringUserSettingsResponse lists: list[BringList] - def __init__(self, hass: HomeAssistant, bring: Bring) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring + ) -> None: """Initialize the Bring data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=90), ) diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index 1dec8f3a5ed..f4540547c4d 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import BringConfigEntry +from .coordinator import BringConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 53329ad637f..13f590ee7c8 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -69,4 +69,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 6c1e8fe9600..bfe93619dbb 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -19,8 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import BringConfigEntry -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity from .util import list_language, sum_attributes diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 9b17deb55a8..4de306273f3 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -26,14 +26,13 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BringConfigEntry from .const import ( ATTR_ITEM_NAME, ATTR_NOTIFICATION_TYPE, DOMAIN, SERVICE_PUSH_NOTIFICATION, ) -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity PARALLEL_UPDATES = 0 diff --git a/mypy.ini b/mypy.ini index ddc5589dc09..2d9821b1c64 100644 --- a/mypy.ini +++ b/mypy.ini @@ -945,6 +945,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.brother.*] check_untyped_defs = true disallow_incomplete_defs = true From f56d058443c1f044bad96d3ee2128903afd57dd1 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 6 Feb 2025 19:39:58 +0900 Subject: [PATCH 241/288] Add switch to LG ThinQ for power control (#137512) Add switch for convenient power control Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/icons.json | 8 ++++++- .../components/lg_thinq/strings.json | 10 ++++++-- homeassistant/components/lg_thinq/switch.py | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 87cf04e0c1a..42ae5746f24 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -7,8 +7,11 @@ "express_mode": { "default": "mdi:snowflake-variant" }, + "express_fridge": { + "default": "mdi:snowflake" + }, "hot_water_mode": { - "default": "mdi:list-status" + "default": "mdi:heat-wave" }, "humidity_warm_mode": { "default": "mdi:heat-wave" @@ -39,6 +42,9 @@ }, "warm_mode": { "default": "mdi:heat-wave" + }, + "display_light": { + "default": "mdi:lightbulb-on-outline" } }, "binary_sensor": { diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index a776dde2054..8f498e0f8a2 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -30,10 +30,13 @@ "name": "Auto mode" }, "express_mode": { - "name": "Ice plus" + "name": "Express mode" + }, + "express_fridge": { + "name": "Express cool" }, "hot_water_mode": { - "name": "Hot water" + "name": "Heating water" }, "humidity_warm_mode": { "name": "Warm mist" @@ -64,6 +67,9 @@ }, "warm_mode": { "name": "Heating" + }, + "display_light": { + "name": "Lighting" } }, "binary_sensor": { diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index 25fd7eb8b64..6d69ce9a314 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -33,6 +33,18 @@ class ThinQSwitchEntityDescription(SwitchEntityDescription): DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[ThinQSwitchEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.AIR_CON_OPERATION_MODE, + translation_key="operation_power", + entity_category=EntityCategory.CONFIG, + ), + ThinQSwitchEntityDescription( + key=ThinQProperty.DISPLAY_LIGHT, + translation_key=ThinQProperty.DISPLAY_LIGHT, + on_key="on", + off_key="off", + entity_category=EntityCategory.CONFIG, + ), ThinQSwitchEntityDescription( key=ThinQProperty.POWER_SAVE_ENABLED, translation_key=ThinQProperty.POWER_SAVE_ENABLED, @@ -121,8 +133,20 @@ DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[ThinQSwitchEntityDescription, ... off_key="false", entity_category=EntityCategory.CONFIG, ), + ThinQSwitchEntityDescription( + key=ThinQProperty.EXPRESS_FRIDGE, + translation_key=ThinQProperty.EXPRESS_FRIDGE, + on_key="true", + off_key="false", + entity_category=EntityCategory.CONFIG, + ), ), DeviceType.SYSTEM_BOILER: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.BOILER_OPERATION_MODE, + translation_key="operation_power", + entity_category=EntityCategory.CONFIG, + ), ThinQSwitchEntityDescription( key=ThinQProperty.HOT_WATER_MODE, translation_key=ThinQProperty.HOT_WATER_MODE, From a4fe0cbe7af2eb1e8bed9607dccf589883b19d66 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Feb 2025 13:43:53 +0100 Subject: [PATCH 242/288] Update mypy-dev to 1.16.0a2 (#137542) --- homeassistant/components/alexa/handlers.py | 2 +- homeassistant/components/androidtv/config_flow.py | 2 +- homeassistant/components/caldav/calendar.py | 2 +- homeassistant/components/light/__init__.py | 6 +++++- homeassistant/components/linear_garage_door/coordinator.py | 4 ++-- homeassistant/components/matter/binary_sensor.py | 3 +++ homeassistant/components/number/const.py | 2 +- homeassistant/components/recorder/history/modern.py | 2 +- homeassistant/components/sensor/const.py | 2 +- homeassistant/components/system_health/__init__.py | 2 +- homeassistant/components/tplink/config_flow.py | 2 +- homeassistant/components/zha/radio_manager.py | 2 +- homeassistant/helpers/entity_registry.py | 4 +--- requirements_test.txt | 2 +- 14 files changed, 21 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 04bef105546..8bd393e2d11 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1531,7 +1531,7 @@ async def async_api_adjust_range( data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_delta = directive.payload["rangeValueDelta"] range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) - response_value: int | None = 0 + response_value: float | None = 0 # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index afaba5175da..d56d4e64b0f 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -387,4 +387,4 @@ def _validate_state_det_rules(state_det_rules: Any) -> list[Any] | None: except ValueError as exc: _LOGGER.warning("Invalid state detection rules: %s", exc) return None - return json_rules # type: ignore[no-any-return] + return json_rules diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index c2bf1b2dce1..7a426112d04 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -174,7 +174,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE def __init__( self, - name: str, + name: str | None, entity_id: str, coordinator: CalDavUpdateCoordinator, unique_id: str | None = None, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d87dcf41161..637ba45c7d9 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -8,7 +8,7 @@ import dataclasses from functools import partial import logging import os -from typing import Any, Final, Self, cast, final +from typing import TYPE_CHECKING, Any, Final, Self, cast, final from propcache.api import cached_property import voluptuous as vol @@ -528,6 +528,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: rgb_color = params.pop(ATTR_RGB_COLOR) assert rgb_color is not None + if TYPE_CHECKING: + rgb_color = cast(tuple[int, int, int], rgb_color) if ColorMode.RGBW in supported_color_modes: params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) elif ColorMode.RGBWW in supported_color_modes: @@ -601,6 +603,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ): rgbww_color = params.pop(ATTR_RGBWW_COLOR) assert rgbww_color is not None + if TYPE_CHECKING: + rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color) rgb_color = color_util.color_rgbww_to_rgb( *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py index 35ccced3274..38b1306ec38 100644 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from linear_garage_door import Linear from linear_garage_door.errors import InvalidLoginError @@ -56,7 +56,7 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): for device in self._devices: device_id = str(device["id"]) state = await linear.get_device_state(device_id) - data[device_id] = LinearDevice(device["name"], state) + data[device_id] = LinearDevice(cast(str, device["name"]), state) return data return await self.execute(update_data) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 6882078a712..484ed94fb90 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.Objects import uint @@ -55,6 +56,8 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = None elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) + if TYPE_CHECKING: + value = cast(bool | None, value) self._attr_is_on = value diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 463fcc919c7..bdde3a4567e 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -494,7 +494,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { SIGNAL_STRENGTH_DECIBELS_MILLIWATT, }, NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure), - NumberDeviceClass.SPEED: set(UnitOfSpeed).union(set(UnitOfVolumetricFlux)), + NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux}, NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature), NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index aed2fcf8508..8958913bce6 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -766,7 +766,7 @@ def _sorted_states_to_dict( attr_cache, start_time_ts, entity_id, - prev_state, # type: ignore[arg-type] + prev_state, first_state[last_updated_ts_idx], no_attributes, ) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 59a87c419e0..c46aca548c8 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -590,7 +590,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SIGNAL_STRENGTH_DECIBELS_MILLIWATT, }, SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure), - SensorDeviceClass.SPEED: set(UnitOfSpeed).union(set(UnitOfVolumetricFlux)), + SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux}, SensorDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 7d2224fc6fc..37e9ee3d929 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -220,7 +220,7 @@ async def handle_info( # Update subscription of all finished tasks for result in done: domain, key = pending_lookup[result] - event_msg = { + event_msg: dict[str, Any] = { "type": "update", "domain": domain, "key": key, diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 9ca2fe80cf9..291a7e78c62 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -328,7 +328,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): host, port = self._async_get_host_port(host) - match_dict = {CONF_HOST: host} + match_dict: dict[str, Any] = {CONF_HOST: host} if port: self.port = port match_dict[CONF_PORT] = port diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index aaf156290a7..6a5d39bc3db 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -420,7 +420,7 @@ class ZhaMultiPANMigrationHelper: self._radio_mgr.radio_type = new_radio_type self._radio_mgr.device_path = new_device_settings[CONF_DEVICE_PATH] self._radio_mgr.device_settings = new_device_settings - device_settings = self._radio_mgr.device_settings.copy() # type: ignore[union-attr] + device_settings = self._radio_mgr.device_settings.copy() # Update the config entry settings self._hass.config_entries.async_update_entry( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 7300b148c77..95a32696228 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -86,9 +86,7 @@ CLEANUP_INTERVAL = 3600 * 24 ORPHANED_ENTITY_KEEP_SECONDS = 3600 * 24 * 30 ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { - # mypy does not understand strenum - val: idx # type: ignore[misc] - for idx, val in enumerate(EntityCategory) + val: idx for idx, val in enumerate(EntityCategory) } ENTITY_CATEGORY_INDEX_TO_VALUE = dict(enumerate(EntityCategory)) diff --git a/requirements_test.txt b/requirements_test.txt index 16983de5706..2731114043b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.10 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a1 +mypy-dev==1.16.0a2 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 From 03d709f1621a2867350dc4ac3ed2261698c62ba2 Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Thu, 6 Feb 2025 14:08:30 +0100 Subject: [PATCH 243/288] Update govee-local-api to 2.0.1 (#137546) --- homeassistant/components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index e813ab545df..cba341cd482 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==2.0.0"] + "requirements": ["govee-local-api==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 877030db00a..e97d39e23bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1055,7 +1055,7 @@ gotailwind==0.3.0 govee-ble==0.43.0 # homeassistant.components.govee_light_local -govee-local-api==2.0.0 +govee-local-api==2.0.1 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0867a965f6..e195c6436ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -905,7 +905,7 @@ gotailwind==0.3.0 govee-ble==0.43.0 # homeassistant.components.govee_light_local -govee-local-api==2.0.0 +govee-local-api==2.0.1 # homeassistant.components.gpsd gps3==0.33.3 From 7822e11894a652c938a9fb17b62e5dc5e4e6f8ea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Feb 2025 15:18:37 +0100 Subject: [PATCH 244/288] Don't overwrite setup state in async_set_domains_to_be_loaded (#137547) --- homeassistant/setup.py | 8 ++++- tests/test_setup.py | 76 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1fa93a80cd5..dc4d0988b91 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -132,7 +132,13 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Keep track of domains which will load but have not yet finished loading """ setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_done_futures.update({domain: hass.loop.create_future() for domain in domains}) + setup_futures = hass.data.setdefault(DATA_SETUP, {}) + old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components + if overlap := old_domains & domains: + _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) + setup_done_futures.update( + {domain: hass.loop.create_future() for domain in domains - old_domains} + ) def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool: diff --git a/tests/test_setup.py b/tests/test_setup.py index 2d15c670cf7..bb221c7cb4c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -363,20 +363,24 @@ async def test_component_failing_setup(hass: HomeAssistant) -> None: async def test_component_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" - setup.async_set_domains_to_be_loaded(hass, {"comp"}) + domain = "comp" + setup.async_set_domains_to_be_loaded(hass, {domain}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Raise exception.""" raise Exception("fail!") # noqa: TRY002 - mock_integration(hass, MockModule("comp", setup=exception_setup)) + mock_integration(hass, MockModule(domain, setup=exception_setup)) - assert not await setup.async_setup_component(hass, "comp", {}) - assert "comp" not in hass.config.components + assert not await setup.async_setup_component(hass, domain, {}) + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components async def test_component_base_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" + domain = "comp" setup.async_set_domains_to_be_loaded(hass, {"comp"}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -389,7 +393,69 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert "comp" not in hass.config.components + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components + + +async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: + """Test async_set_domains_to_be_loaded.""" + domain_good = "comp_good" + domain_bad = "comp_bad" + domain_base_exception = "comp_base_exception" + domain_exception = "comp_exception" + domains = {domain_good, domain_bad, domain_exception, domain_base_exception} + setup.async_set_domains_to_be_loaded(hass, domains) + + assert set(hass.data[setup.DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + + # Calling async_set_domains_to_be_loaded again should not create new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert setup_done == hass.data[setup.DATA_SETUP_DONE] + + def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Success.""" + return True + + def bad_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Fail.""" + return False + + def base_exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise BaseException("fail!") # noqa: TRY002 + + def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise Exception("fail!") # noqa: TRY002 + + mock_integration(hass, MockModule(domain_good, setup=good_setup)) + mock_integration(hass, MockModule(domain_bad, setup=bad_setup)) + mock_integration( + hass, MockModule(domain_base_exception, setup=base_exception_setup) + ) + mock_integration(hass, MockModule(domain_exception, setup=exception_setup)) + + # Set up the four components + assert await setup.async_setup_component(hass, domain_good, {}) + assert not await setup.async_setup_component(hass, domain_bad, {}) + assert not await setup.async_setup_component(hass, domain_exception, {}) + with pytest.raises(BaseException, match="fail!"): + await setup.async_setup_component(hass, domain_base_exception, {}) + + # Check the result of the setup + assert not hass.data[setup.DATA_SETUP_DONE] + assert set(hass.data[setup.DATA_SETUP]) == { + domain_bad, + domain_exception, + domain_base_exception, + } + assert set(hass.config.components) == {domain_good} + + # Calling async_set_domains_to_be_loaded again should not create any new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert not hass.data[setup.DATA_SETUP_DONE] async def test_component_setup_with_validation_and_dependency( From da162676abaf42e97ee4836a93468b0aa2619cab Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 6 Feb 2025 07:30:35 -0700 Subject: [PATCH 245/288] Fix translation key typo in coinbase options (#137543) Fix translation key in coinbase --- homeassistant/components/coinbase/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 96bf021e394..7e55b6ec1c2 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -29,7 +29,7 @@ "account_balance_currencies": "Wallet balances to report.", "exchange_rate_currencies": "Exchange rates to report.", "exchange_base": "Base currency for exchange rate sensors.", - "exchnage_rate_precision": "Number of decimal places for exchange rates." + "exchange_rate_precision": "Number of decimal places for exchange rates." } } }, From 5f6068eea4b23d4b8100de0830ee06532638524f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:41:27 +0100 Subject: [PATCH 246/288] Skip building wheels for electrickiwi-api (#137556) --- script/gen_requirements_all.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ef57b9140ce..dc4f2383b64 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,6 +50,12 @@ INCLUDED_REQUIREMENTS_WHEELS = { "pyuserinput", } +EXCLUDED_REQUIREMENTS_WHEELS = { + # Exclude 'electrickiwi-api' temporarily, until <3.13 pin is removed upstream. + # https://github.com/mikey0000/EK-API/pull/1 + "electrickiwi-api", +} + # Requirements to exclude or include when running github actions. # Requirements listed in "exclude" will be commented-out in @@ -64,7 +70,7 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { "markers": {}, }, "wheels_aarch64": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, @@ -73,22 +79,23 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. "wheels_armhf": { - "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, + "exclude": EXCLUDED_REQUIREMENTS_WHEELS + | {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_armv7": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_amd64": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_i386": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, From af0f9dec1f4fe74070095bce176c9f5fe33ce05e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:42:29 +0100 Subject: [PATCH 247/288] Prevent packages from accidentally installing poetry (#137560) Co-authored-by: Martin Hjelmare --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd768198541..85d6ba2c84c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -168,6 +168,10 @@ pysnmplib==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 +# Poetry is a build dependency. Installing it as a runtime dependency almost +# always indicates an issue with library requirements. +poetry==1000000000.0.0 + # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dc4f2383b64..107d202cfdb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -206,6 +206,10 @@ pysnmplib==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 +# Poetry is a build dependency. Installing it as a runtime dependency almost +# always indicates an issue with library requirements. +poetry==1000000000.0.0 + # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. From ba14933ae7efbffcf9bf0cfb5d0532d9d4ce0d1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Feb 2025 10:00:29 -0600 Subject: [PATCH 248/288] Revert "Add `PaddleSwitchPico` (Pico Paddle Remote) device trigger to Lutron Caseta" (#137571) --- .../components/lutron_caseta/device_trigger.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 79b792935a8..0b432f88045 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,20 +277,6 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( } ) -PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { - "button_0": 2, - "button_2": 4, -} -PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { - "button_0": 0, - "button_2": 2, -} -PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), - } -) - DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -302,7 +288,6 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -315,7 +300,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -328,7 +312,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -343,7 +326,6 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) From 57133c199f643b11e77485fb78888a96489d8313 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Feb 2025 17:01:19 +0100 Subject: [PATCH 249/288] Fix spelling of "Roborock" and adapt action descriptions to HA standards (#137570) - change two occurrences of "roborock" to "Roborock" - change "url" to "URL - Reword three action descriptions to use third-person plural, following HA standards --- homeassistant/components/roborock/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7005344614c..8968ac020a2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -23,8 +23,8 @@ "invalid_email": "There is no account associated with the email you entered, please try again.", "invalid_email_format": "There is an issue with the formatting of your email - please try again.", "too_frequent_code_requests": "You have attempted to request too many codes. Try again later.", - "unknown_roborock": "There was an unknown roborock exception - please check your logs.", - "unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.", + "unknown_roborock": "There was an unknown Roborock exception - please check your logs.", + "unknown_url": "There was an issue determining the correct URL for your Roborock account - please check your logs.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -436,11 +436,11 @@ "services": { "get_maps": { "name": "Get maps", - "description": "Get the map and room information of your device." + "description": "Retrieves the map and room information of your device." }, "set_vacuum_goto_position": { "name": "Go to position", - "description": "Send the vacuum to a specific position.", + "description": "Sends the vacuum to a specific position.", "fields": { "x": { "name": "X-coordinate", @@ -454,7 +454,7 @@ }, "get_vacuum_current_position": { "name": "Get current position", - "description": "Get the current position of the vacuum." + "description": "Retrieves the current position of the vacuum." } } } From b4559e03428b2b3a6db92fa1eb4c28f9c6155e85 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Feb 2025 17:05:04 +0100 Subject: [PATCH 250/288] Several fixes in user-facing strings of the SQL integration (#137438) - use proper sentence-casing for all strings - replace "unit of measure" with correct "unit of measurement" - replace "HA recorder" with "Home Assistant Recorder" as this is a name that is not translated - add some details from the online docs to descriptions --- homeassistant/components/sql/strings.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index cd36ccf7731..ac861e72b72 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -5,7 +5,7 @@ }, "error": { "db_url_invalid": "Database URL invalid", - "query_invalid": "SQL Query invalid", + "query_invalid": "SQL query invalid", "query_no_read_only": "SQL query must be read-only", "multiple_queries": "Multiple SQL queries are not supported", "column_invalid": "The column `{column}` is not returned by the query" @@ -15,22 +15,22 @@ "data": { "db_url": "Database URL", "name": "[%key:common::config_flow::data::name%]", - "query": "Select Query", + "query": "Select query", "column": "Column", - "unit_of_measurement": "Unit of Measure", - "value_template": "Value Template", - "device_class": "Device Class", - "state_class": "State Class" + "unit_of_measurement": "Unit of measurement", + "value_template": "Value template", + "device_class": "Device class", + "state_class": "State class" }, "data_description": { - "db_url": "Database URL, leave empty to use HA recorder database", - "name": "Name that will be used for Config Entry and also the Sensor", + "db_url": "Leave empty to use Home Assistant Recorder database", + "name": "Name that will be used for config entry and also the sensor", "query": "Query to run, needs to start with 'SELECT'", "column": "Column for returned query to present as state", - "unit_of_measurement": "Unit of Measure (optional)", - "value_template": "Value Template (optional)", + "unit_of_measurement": "The unit of measurement for the sensor (optional)", + "value_template": "Template to extract a value from the payload (optional)", "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state_class of the sensor" + "state_class": "The state class of the sensor" } } } From 634b754168c19a51b72079749cccdceec5551aea Mon Sep 17 00:00:00 2001 From: Dennis Effing Date: Thu, 6 Feb 2025 17:37:10 +0100 Subject: [PATCH 251/288] Fix Overseerr webhook configuration JSON (#137572) Co-authored-by: Lars Jouon --- homeassistant/components/overseerr/const.py | 2 +- tests/components/overseerr/fixtures/webhook_config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 5c33ca3fcec..2aa0879ffed 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -27,7 +27,7 @@ REGISTERED_NOTIFICATIONS = ( JSON_PAYLOAD = ( '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' - '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_id\\":\\"{{media_tmdbid}}\\",\\"t' 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index 40028e1f80f..2b3388444d2 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 222, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_id\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" } } From 16390d56b64b28b9530ab62331a1f05ea0977a53 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Feb 2025 10:53:55 -0600 Subject: [PATCH 252/288] Add excluded domains to broadcast intent (#137566) --- .../components/assist_satellite/intent.py | 46 +++++++++++++------ .../assist_satellite/test_intent.py | 46 +++++++++++++------ 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py index 75396cf138f..7612753e8c4 100644 --- a/homeassistant/components/assist_satellite/intent.py +++ b/homeassistant/components/assist_satellite/intent.py @@ -1,5 +1,7 @@ """Assist Satellite intents.""" +from typing import Final + import voluptuous as vol from homeassistant.core import HomeAssistant @@ -7,6 +9,8 @@ from homeassistant.helpers import entity_registry as er, intent from .const import DOMAIN, AssistSatelliteEntityFeature +EXCLUDED_DOMAINS: Final[set[str]] = {"voip"} + async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the intents.""" @@ -30,19 +34,36 @@ class BroadcastIntentHandler(intent.IntentHandler): ent_reg = er.async_get(hass) # Find all assist satellite entities that are not the one invoking the intent - entities = { - entity: entry - for entity in hass.states.async_entity_ids(DOMAIN) - if (entry := ent_reg.async_get(entity)) - and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE - } + entities: dict[str, er.RegistryEntry] = {} + for entity in hass.states.async_entity_ids(DOMAIN): + entry = ent_reg.async_get(entity) + if ( + (entry is None) + or ( + # Supports announce + not ( + entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + ) + ) + # Not the invoking device + or (intent_obj.device_id and (entry.device_id == intent_obj.device_id)) + ): + # Skip satellite + continue - if intent_obj.device_id: - entities = { - entity: entry - for entity, entry in entities.items() - if entry.device_id != intent_obj.device_id - } + # Check domain of config entry against excluded domains + if ( + entry.config_entry_id + and ( + config_entry := hass.config_entries.async_get_entry( + entry.config_entry_id + ) + ) + and (config_entry.domain in EXCLUDED_DOMAINS) + ): + continue + + entities[entity] = entry await hass.services.async_call( DOMAIN, @@ -54,7 +75,6 @@ class BroadcastIntentHandler(intent.IntentHandler): ) response = intent_obj.create_response() - response.async_set_speech("Done") response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_results( success_results=[ diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py index 27107c7d2e9..9304229dbe3 100644 --- a/tests/components/assist_satellite/test_intent.py +++ b/tests/components/assist_satellite/test_intent.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from .conftest import MockAssistSatellite +from .conftest import TEST_DOMAIN, MockAssistSatellite @pytest.fixture @@ -65,12 +65,7 @@ async def test_broadcast_intent( }, "language": "en", "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "Done", - } - }, + "speech": {}, # response comes from intents } assert len(entity.announcements) == 1 assert len(entity2.announcements) == 1 @@ -99,12 +94,37 @@ async def test_broadcast_intent( }, "language": "en", "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "Done", - } - }, + "speech": {}, # response comes from intents } assert len(entity.announcements) == 1 assert len(entity2.announcements) == 2 + + +async def test_broadcast_intent_excluded_domains( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test that the broadcast intent filters out entities in excluded domains.""" + + # Exclude the "test" domain + with patch( + "homeassistant.components.assist_satellite.intent.EXCLUDED_DOMAINS", + new={TEST_DOMAIN}, + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [], # no satellites + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": {}, + } From 711dd7f05b109b39c16c439ff56149694bddb0e9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 6 Feb 2025 18:42:22 +0100 Subject: [PATCH 253/288] Explicitly pass in the config_entry in madvr coordinator init (#137468) explicitly pass in the config_entry in coordinator init --- homeassistant/components/madvr/__init__.py | 8 ++------ homeassistant/components/madvr/binary_sensor.py | 3 +-- homeassistant/components/madvr/coordinator.py | 9 +++++---- homeassistant/components/madvr/diagnostics.py | 2 +- homeassistant/components/madvr/remote.py | 3 +-- homeassistant/components/madvr/sensor.py | 3 +-- 6 files changed, 11 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/madvr/__init__.py b/homeassistant/components/madvr/__init__.py index bb42adb21fc..cf681bd0b65 100644 --- a/homeassistant/components/madvr/__init__.py +++ b/homeassistant/components/madvr/__init__.py @@ -6,17 +6,13 @@ import logging from madvr.madvr import Madvr -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.REMOTE, Platform.SENSOR] - -type MadVRConfigEntry = ConfigEntry[MadVRCoordinator] - _LOGGER = logging.getLogger(__name__) @@ -41,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> boo connect_timeout=10, loop=hass.loop, ) - coordinator = MadVRCoordinator(hass, madVRClient) + coordinator = MadVRCoordinator(hass, entry, madVRClient) entry.runtime_data = coordinator diff --git a/homeassistant/components/madvr/binary_sensor.py b/homeassistant/components/madvr/binary_sensor.py index 6a31f9cdcda..b6820f94fea 100644 --- a/homeassistant/components/madvr/binary_sensor.py +++ b/homeassistant/components/madvr/binary_sensor.py @@ -12,8 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MadVRConfigEntry -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity _HDR_FLAG = "hdr_flag" diff --git a/homeassistant/components/madvr/coordinator.py b/homeassistant/components/madvr/coordinator.py index 4031ba127f7..c1ed87fbee7 100644 --- a/homeassistant/components/madvr/coordinator.py +++ b/homeassistant/components/madvr/coordinator.py @@ -3,10 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any from madvr.madvr import Madvr +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,8 +15,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import MadVRConfigEntry +type MadVRConfigEntry = ConfigEntry[MadVRCoordinator] class MadVRCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -26,10 +26,11 @@ class MadVRCoordinator(DataUpdateCoordinator[dict[str, Any]]): def __init__( self, hass: HomeAssistant, + config_entry: MadVRConfigEntry, client: Madvr, ) -> None: """Initialize madvr coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN) + super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) assert self.config_entry.unique_id self.mac = self.config_entry.unique_id self.client = client diff --git a/homeassistant/components/madvr/diagnostics.py b/homeassistant/components/madvr/diagnostics.py index f6261d27305..39e17a13d6f 100644 --- a/homeassistant/components/madvr/diagnostics.py +++ b/homeassistant/components/madvr/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import MadVRConfigEntry +from .coordinator import MadVRConfigEntry TO_REDACT = [CONF_HOST] diff --git a/homeassistant/components/madvr/remote.py b/homeassistant/components/madvr/remote.py index 4fe02b7ae47..032a1d718f5 100644 --- a/homeassistant/components/madvr/remote.py +++ b/homeassistant/components/madvr/remote.py @@ -10,8 +10,7 @@ from homeassistant.components.remote import RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MadVRConfigEntry -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/madvr/sensor.py b/homeassistant/components/madvr/sensor.py index 047b8bb83e6..e54e9dca476 100644 --- a/homeassistant/components/madvr/sensor.py +++ b/homeassistant/components/madvr/sensor.py @@ -16,7 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MadVRConfigEntry from .const import ( ASPECT_DEC, ASPECT_INT, @@ -45,7 +44,7 @@ from .const import ( TEMP_HDMI, TEMP_MAINBOARD, ) -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity From 9a9822140eb4a80da234206225f203fcd1a2a56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 6 Feb 2025 17:54:59 +0000 Subject: [PATCH 254/288] Replace string literal with existing const in ZHA (#137576) --- homeassistant/components/zha/websocket_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index d562a807a4f..07d897bcfd6 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -37,6 +37,7 @@ from zha.application.const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ZHA_CLUSTER_HANDLER_MSG, + ZHA_GW_MSG, ) from zha.application.gateway import Gateway from zha.application.helpers import ( @@ -330,7 +331,7 @@ async def websocket_permit_devices( connection.send_message(websocket_api.event_message(msg["id"], data)) remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages + hass, ZHA_GW_MSG, forward_messages ) @callback From b6d9e4f1b18a16965ed9e2f9620b964cbdc469e7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:12:57 +0100 Subject: [PATCH 255/288] Plugwise: remove user-config of port (#137584) --- homeassistant/components/plugwise/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index a94000934eb..09c5392f12a 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -59,8 +59,7 @@ def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: schema = schema.extend( { vol.Required(CONF_HOST): str, - # Port under investigation for removal (hence not added in #132878) - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PASSWORD): str, vol.Required(CONF_USERNAME, default=SMILE): vol.In( {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} ), @@ -197,6 +196,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + user_input[CONF_PORT] = DEFAULT_PORT if self.discovery_info: user_input[CONF_HOST] = self.discovery_info.host user_input[CONF_PORT] = self.discovery_info.port From ec587e60e314e2ca9aad3d35d3903575cd8b3c47 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Feb 2025 19:25:02 +0100 Subject: [PATCH 256/288] Fix sentence-casing in user-facing strings of Coinbase integration (#137586) --- homeassistant/components/coinbase/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 7e55b6ec1c2..74510731b7a 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -2,19 +2,19 @@ "config": { "step": { "user": { - "title": "Coinbase API Key Details", + "title": "Coinbase API key details", "description": "Please enter the details of your API key as provided by Coinbase.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "api_token": "API Secret" + "api_token": "API secret" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API Key.", - "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API Secret.", + "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API key.", + "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API secret.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -24,7 +24,7 @@ "options": { "step": { "init": { - "description": "Adjust Coinbase Options", + "description": "Adjust Coinbase options", "data": { "account_balance_currencies": "Wallet balances to report.", "exchange_rate_currencies": "Exchange rates to report.", From 44c79f4b9c6972a8a18d0534e09fa0d7841744b9 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:32:41 +0100 Subject: [PATCH 257/288] Correct state and icon if fan is in standby mode in ViCare integration (#137565) * handle standby mode * update snapshot --- homeassistant/components/vicare/fan.py | 5 ++ .../components/vicare/snapshots/test_fan.ambr | 68 +------------------ 2 files changed, 7 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 10983a7ad24..c5e24f46c33 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -196,6 +196,9 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" + if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + return False + return self.percentage is not None and self.percentage > 0 def turn_off(self, **kwargs: Any) -> None: @@ -206,6 +209,8 @@ class ViCareFan(ViCareEntity, FanEntity): @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" + if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: return "mdi:fan-clock" diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 745e77dac5c..b5b02af39b1 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,68 +1,4 @@ # serializer version: 1 -# name: test_all_entities[fan.model0_ventilation-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'preset_modes': list([ - , - , - , - , - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.model0_ventilation', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:fan', - 'original_name': 'Ventilation', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'ventilation', - 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[fan.model0_ventilation-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Ventilation', - 'icon': 'mdi:fan', - 'percentage': 0, - 'percentage_step': 25.0, - 'preset_mode': None, - 'preset_modes': list([ - , - , - , - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.model0_ventilation', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[fan.model1_ventilation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -92,7 +28,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:fan', + 'original_icon': 'mdi:fan-off', 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, @@ -106,7 +42,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model1 Ventilation', - 'icon': 'mdi:fan', + 'icon': 'mdi:fan-off', 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, From 3297b27dce7a604c66845ed4f0574804aed68038 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Thu, 6 Feb 2025 11:40:12 -0700 Subject: [PATCH 258/288] Explicitly pass in the config_entry in vesync (#137498) * Explicitly pass in the config_entry in vesync * Feedback --- homeassistant/components/vesync/__init__.py | 2 +- homeassistant/components/vesync/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 1c55d932425..4951bdb2dc1 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager - coordinator = VeSyncDataCoordinator(hass, manager) + coordinator = VeSyncDataCoordinator(hass, config_entry, manager) # Store coordinator at domain level since only single integration instance is permitted. hass.data[DOMAIN][VS_COORDINATOR] = coordinator diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py index f3df2970fdb..e8c8396bfb4 100644 --- a/homeassistant/components/vesync/coordinator.py +++ b/homeassistant/components/vesync/coordinator.py @@ -7,6 +7,7 @@ import logging from pyvesync import VeSync +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -18,13 +19,18 @@ _LOGGER = logging.getLogger(__name__) class VeSyncDataCoordinator(DataUpdateCoordinator[None]): """Class representing data coordinator for VeSync devices.""" - def __init__(self, hass: HomeAssistant, manager: VeSync) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync + ) -> None: """Initialize.""" self._manager = manager super().__init__( hass, _LOGGER, + config_entry=config_entry, name="VeSyncDataCoordinator", update_interval=timedelta(seconds=UPDATE_INTERVAL), ) From 603a1ed69c6884612950f64d51ed96a1a923810b Mon Sep 17 00:00:00 2001 From: Jasper Wiegratz <656460+jwhb@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:42:28 +0100 Subject: [PATCH 259/288] Fix sending polls to Telegram threads (#137553) Fix sending poll to Telegram thread --- homeassistant/components/telegram_bot/__init__.py | 2 ++ tests/components/telegram_bot/test_telegram_bot.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f744265e1c2..fa3ec1dc4f7 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -175,6 +175,7 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, vol.Optional(ATTR_TIMEOUT): cv.positive_int, vol.Optional(ATTR_MESSAGE_TAG): cv.string, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), }, extra=vol.ALLOW_EXTRA, ) @@ -216,6 +217,7 @@ SERVICE_SCHEMA_SEND_POLL = vol.Schema( vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_TIMEOUT): cv.positive_int, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), } ) diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index be6b5b31325..c9038003cfc 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -184,7 +184,7 @@ async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> Non assert len(events) == 1 assert events[0].context == context - assert events[0].data[ATTR_MESSAGE_THREAD_ID] == "123" + assert events[0].data[ATTR_MESSAGE_THREAD_ID] == 123 async def test_webhook_endpoint_generates_telegram_text_event( From 167fb37929122507d60bc128bd171de610d6889a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 6 Feb 2025 19:45:53 +0100 Subject: [PATCH 260/288] Update library for smhi (#136375) * Update library for smhi * Imports * Fixes --- homeassistant/components/smhi/config_flow.py | 6 +- homeassistant/components/smhi/manifest.json | 4 +- homeassistant/components/smhi/weather.py | 64 ++++---- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/smhi/test_config_flow.py | 16 +- tests/components/smhi/test_init.py | 10 +- tests/components/smhi/test_weather.py | 145 +++++++++++-------- 8 files changed, 138 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 2521df3a333..387edfc6e11 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from smhi.smhi_lib import Smhi, SmhiForecastException +from pysmhi import SmhiForecastException, SMHIPointForecast import voluptuous as vol from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -26,9 +26,9 @@ async def async_check_location( ) -> bool: """Return true if location is ok.""" session = aiohttp_client.async_get_clientsession(hass) - smhi_api = Smhi(longitude, latitude, session=session) + smhi_api = SMHIPointForecast(str(longitude), str(latitude), session=session) try: - await smhi_api.async_get_forecast() + await smhi_api.async_get_daily_forecast() except SmhiForecastException: return False diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 645ace41cab..fc3af634764 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", - "loggers": ["smhi"], - "requirements": ["smhi-pkg==1.0.19"] + "loggers": ["pysmhi"], + "requirements": ["pysmhi==1.0.0"] } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index d43ca4465ae..1707afa2fca 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -9,8 +9,7 @@ import logging from typing import Any, Final import aiohttp -from smhi import Smhi -from smhi.smhi_lib import SmhiForecast, SmhiForecastException +from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -59,7 +58,7 @@ from homeassistant.helpers import aiohttp_client, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.util import Throttle from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT @@ -139,10 +138,10 @@ class SmhiWeather(WeatherEntity): ) -> None: """Initialize the SMHI weather entity.""" self._attr_unique_id = f"{latitude}, {longitude}" - self._forecast_daily: list[SmhiForecast] | None = None - self._forecast_hourly: list[SmhiForecast] | None = None + self._forecast_daily: list[SMHIForecast] | None = None + self._forecast_hourly: list[SMHIForecast] | None = None self._fail_count = 0 - self._smhi_api = Smhi(longitude, latitude, session=session) + self._smhi_api = SMHIPointForecast(longitude, latitude, session=session) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{latitude}, {longitude}")}, @@ -156,7 +155,7 @@ class SmhiWeather(WeatherEntity): """Return additional attributes.""" if self._forecast_daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0].thunder, + ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0]["thunder"], } return None @@ -165,8 +164,8 @@ class SmhiWeather(WeatherEntity): """Refresh the forecast data from SMHI weather API.""" try: async with asyncio.timeout(TIMEOUT): - self._forecast_daily = await self._smhi_api.async_get_forecast() - self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() + self._forecast_daily = await self._smhi_api.async_get_daily_forecast() + self._forecast_hourly = await self._smhi_api.async_get_hourly_forecast() self._fail_count = 0 except (TimeoutError, SmhiForecastException): _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") @@ -176,15 +175,15 @@ class SmhiWeather(WeatherEntity): return if self._forecast_daily: - self._attr_native_temperature = self._forecast_daily[0].temperature - self._attr_humidity = self._forecast_daily[0].humidity - self._attr_native_wind_speed = self._forecast_daily[0].wind_speed - self._attr_wind_bearing = self._forecast_daily[0].wind_direction - self._attr_native_visibility = self._forecast_daily[0].horizontal_visibility - self._attr_native_pressure = self._forecast_daily[0].pressure - self._attr_native_wind_gust_speed = self._forecast_daily[0].wind_gust - self._attr_cloud_coverage = self._forecast_daily[0].cloudiness - self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0].symbol) + self._attr_native_temperature = self._forecast_daily[0]["temperature"] + self._attr_humidity = self._forecast_daily[0]["humidity"] + self._attr_native_wind_speed = self._forecast_daily[0]["wind_speed"] + self._attr_wind_bearing = self._forecast_daily[0]["wind_direction"] + self._attr_native_visibility = self._forecast_daily[0]["visibility"] + self._attr_native_pressure = self._forecast_daily[0]["pressure"] + self._attr_native_wind_gust_speed = self._forecast_daily[0]["wind_gust"] + self._attr_cloud_coverage = self._forecast_daily[0]["total_cloud"] + self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0]["symbol"]) if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( self.hass ): @@ -196,7 +195,7 @@ class SmhiWeather(WeatherEntity): await self.async_update(no_throttle=True) def _get_forecast_data( - self, forecast_data: list[SmhiForecast] | None + self, forecast_data: list[SMHIForecast] | None ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -205,25 +204,28 @@ class SmhiWeather(WeatherEntity): data: list[Forecast] = [] for forecast in forecast_data[1:]: - condition = CONDITION_MAP.get(forecast.symbol) + condition = CONDITION_MAP.get(forecast["symbol"]) if condition == ATTR_CONDITION_SUNNY and not sun.is_up( - self.hass, forecast.valid_time.replace(tzinfo=dt_util.UTC) + self.hass, forecast["valid_time"] ): condition = ATTR_CONDITION_CLEAR_NIGHT data.append( { - ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), - ATTR_FORECAST_NATIVE_TEMP: forecast.temperature_max, - ATTR_FORECAST_NATIVE_TEMP_LOW: forecast.temperature_min, - ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.total_precipitation, + ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), + ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast["temperature_min"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.get( + "total_precipitation" + ) + or forecast["mean_precipitation"], ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, - ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, - ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, - ATTR_FORECAST_HUMIDITY: forecast.humidity, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast.wind_gust, - ATTR_FORECAST_CLOUD_COVERAGE: forecast.cloudiness, + ATTR_FORECAST_NATIVE_PRESSURE: forecast["pressure"], + ATTR_FORECAST_WIND_BEARING: forecast["wind_direction"], + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind_speed"], + ATTR_FORECAST_HUMIDITY: forecast["humidity"], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast["wind_gust"], + ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) diff --git a/requirements_all.txt b/requirements_all.txt index e97d39e23bd..a279f001e62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2309,6 +2309,9 @@ pysmartthings==0.7.8 # homeassistant.components.smarty pysmarty2==0.10.1 +# homeassistant.components.smhi +pysmhi==1.0.0 + # homeassistant.components.edl21 pysml==0.0.12 @@ -2738,9 +2741,6 @@ slixmpp==1.8.5 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 -# homeassistant.components.smhi -smhi-pkg==1.0.19 - # homeassistant.components.snapcast snapcast==2.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e195c6436ab..8fde12b53d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1881,6 +1881,9 @@ pysmartthings==0.7.8 # homeassistant.components.smarty pysmarty2==0.10.1 +# homeassistant.components.smhi +pysmhi==1.0.0 + # homeassistant.components.edl21 pysml==0.0.12 @@ -2205,9 +2208,6 @@ slack_sdk==3.33.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 -# homeassistant.components.smhi -smhi-pkg==1.0.19 - # homeassistant.components.snapcast snapcast==2.3.6 diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 362adebe416..524aad873f9 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from smhi.smhi_lib import SmhiForecastException +from pysmhi import SmhiForecastException from homeassistant import config_entries from homeassistant.components.smhi.const import DOMAIN @@ -31,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -66,7 +66,7 @@ async def test_form(hass: HomeAssistant) -> None: ) with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -102,7 +102,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException, ): result2 = await hass.config_entries.flow.async_configure( @@ -122,7 +122,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: # Continue flow with new coordinates with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -170,7 +170,7 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ): result2 = await hass.config_entries.flow.async_configure( @@ -218,7 +218,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException, ): result = await hass.config_entries.flow.async_configure( @@ -237,7 +237,7 @@ async def test_reconfigure_flow( with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index d00742d4900..f301e684e3e 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,6 +1,6 @@ """Test SMHI component setup process.""" -from smhi.smhi_lib import APIURL_TEMPLATE +from pysmhi.const import API_POINT_FORECAST from homeassistant.components.smhi.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -17,7 +17,7 @@ async def test_setup_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test setup entry.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -35,7 +35,7 @@ async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test remove entry.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -62,7 +62,7 @@ async def test_migrate_entry( api_response: str, ) -> None: """Test migrate entry data.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -97,7 +97,7 @@ async def test_migrate_from_future_version( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test migrate entry not possible from future version.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index cc6902710bd..a39cb72d4b8 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -4,8 +4,9 @@ from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time +from pysmhi import SMHIForecast, SmhiForecastException +from pysmhi.const import API_POINT_FORECAST import pytest -from smhi.smhi_lib import APIURL_TEMPLATE, SmhiForecast, SmhiForecastException from syrupy.assertion import SnapshotAssertion from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY @@ -44,7 +45,7 @@ async def test_setup_hass( snapshot: SnapshotAssertion, ) -> None: """Test for successfully setting up the smhi integration.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -54,7 +55,7 @@ async def test_setup_hass( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 # Testing the actual entity state for # deeper testing than normal unity test @@ -75,7 +76,7 @@ async def test_clear_night( """Test for successfully setting up the smhi integration.""" hass.config.latitude = "59.32624" hass.config.longitude = "17.84197" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response_night) @@ -85,7 +86,7 @@ async def test_clear_night( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 state = hass.states.get(ENTITY_ID) @@ -109,7 +110,7 @@ async def test_properties_no_data(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): await hass.config_entries.async_setup(entry.entry_id) @@ -134,61 +135,77 @@ async def test_properties_no_data(hass: HomeAssistant) -> None: async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: """Test behaviour when unknown symbol from API.""" - data = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 1, 0, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 1, 0, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) - - data2 = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data2 = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 1, 12, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 1, 12, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) - - data3 = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data3 = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 2, 12, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 2, 0, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) testdata = [data, data2, data3] @@ -198,11 +215,11 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast_hour", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -237,7 +254,7 @@ async def test_refresh_weather_forecast_retry( now = dt_util.utcnow() with patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: await hass.config_entries.async_setup(entry.entry_id) @@ -352,7 +369,7 @@ async def test_custom_speed_unit( api_response: str, ) -> None: """Test Wind Gust speed with custom unit.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -389,7 +406,7 @@ async def test_forecast_services( snapshot: SnapshotAssertion, ) -> None: """Test multiple forecast.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -440,7 +457,7 @@ async def test_forecast_services( assert msg["type"] == "event" forecast1 = msg["event"]["forecast"] - assert len(forecast1) == 72 + assert len(forecast1) == 52 assert forecast1[0] == snapshot assert forecast1[6] == snapshot @@ -453,7 +470,7 @@ async def test_forecast_services_lack_of_data( snapshot: SnapshotAssertion, ) -> None: """Test forecast lacking data.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response_lack_data) @@ -498,7 +515,7 @@ async def test_forecast_service( service: str, ) -> None: """Test forecast service.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) From 21b18d8449803c115482c474b0a34349e89ce6c1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:51:40 +0100 Subject: [PATCH 261/288] Add exception translations to Bring! integration (#137515) --- homeassistant/components/bring/coordinator.py | 18 ++++++++++++++---- .../components/bring/quality_scale.yaml | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 23082180fe1..db5ed9736a8 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -66,9 +66,15 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): try: self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: - raise UpdateFailed("Unable to connect and retrieve data from bring") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e except BringParseException as e: - raise UpdateFailed("Unable to parse response from bring") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e except BringAuthException as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -90,10 +96,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): items = await self.bring.get_list(lst.listUuid) except BringRequestException as e: raise UpdateFailed( - "Unable to connect and retrieve data from bring" + translation_domain=DOMAIN, + translation_key="setup_request_exception", ) from e except BringParseException as e: - raise UpdateFailed("Unable to parse response from bring") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e else: list_dict[lst.listUuid] = BringData(lst, items) diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 13f590ee7c8..2714b92680b 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -58,7 +58,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: From d0bca12632a1f730fad300cc5def89f5295e6330 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Feb 2025 20:19:23 +0100 Subject: [PATCH 262/288] Bump python-overseerr to 0.7.0 (#137590) --- homeassistant/components/overseerr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 396b9d7000b..6258481adcf 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["python-overseerr==0.6.0"] + "requirements": ["python-overseerr==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a279f001e62..8b9d6477941 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2443,7 +2443,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.6.0 +python-overseerr==0.7.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fde12b53d7..ec807529e3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1979,7 +1979,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.6.0 +python-overseerr==0.7.0 # homeassistant.components.picnic python-picnic-api==1.1.0 From 981f3945c8f4c30f74203cf08cf17449069b0369 Mon Sep 17 00:00:00 2001 From: Ron Date: Thu, 6 Feb 2025 20:19:42 +0100 Subject: [PATCH 263/288] Bump pyfireservicerota to 0.0.46 (#137589) --- homeassistant/components/fireservicerota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index 7826115fa3f..945ef141887 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fireservicerota", "iot_class": "cloud_polling", "loggers": ["pyfireservicerota"], - "requirements": ["pyfireservicerota==0.0.43"] + "requirements": ["pyfireservicerota==0.0.46"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b9d6477941..b5672f4a699 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1957,7 +1957,7 @@ pyfibaro==0.8.0 pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec807529e3e..79f838856bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyfibaro==0.8.0 pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 From 89d489b391ab01fd11c8eaaa2d3cea483aa60e3b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 6 Feb 2025 20:23:28 +0100 Subject: [PATCH 264/288] Bump reolink-aio to 0.11.10 (#137591) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index fb3c096ee41..505358a07f7 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.9"] + "requirements": ["reolink-aio==0.11.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index b5672f4a699..7eb9fc0a1a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2609,7 +2609,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.9 +reolink-aio==0.11.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79f838856bf..1999e2cd67c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2112,7 +2112,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.9 +reolink-aio==0.11.10 # homeassistant.components.rflink rflink==0.0.66 From b4ecd9739a96bd84fe184ca3fc0529c6c628a06b Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 6 Feb 2025 20:29:47 +0100 Subject: [PATCH 265/288] Bump eheimdigital to 1.0.6 (#137587) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 7747ca4f95d..1d1ca6f84c7 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.5"], + "requirements": ["eheimdigital==1.0.6"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 7eb9fc0a1a1..432f30c5966 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -821,7 +821,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.5 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1999e2cd67c..d21e044bd89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -699,7 +699,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.5 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.12 From 75772ae40f20f8b3588b7be6b0eedfcdc1efe755 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Feb 2025 20:36:17 +0100 Subject: [PATCH 266/288] Fix sentence-casing in user-facing strings of here_travel_time (#137593) --- .../components/here_travel_time/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index cfa14a3e3ca..c0534fa7154 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -5,11 +5,11 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", - "mode": "Travel Mode" + "mode": "Travel mode" } }, "origin_menu": { - "title": "Choose Origin", + "title": "Choose origin", "menu_options": { "origin_coordinates": "Using a map location", "origin_entity": "Using an entity" @@ -28,7 +28,7 @@ } }, "destination_menu": { - "title": "Choose Destination", + "title": "Choose destination", "menu_options": { "destination_coordinates": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_coordinates%]", "destination_entity": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_entity%]" @@ -60,13 +60,13 @@ "step": { "init": { "data": { - "traffic_mode": "Traffic Mode", - "route_mode": "Route Mode", + "traffic_mode": "Traffic mode", + "route_mode": "Route mode", "unit_system": "Unit system" } }, "time_menu": { - "title": "Choose Time Type", + "title": "Choose time type", "menu_options": { "departure_time": "Configure a departure time", "arrival_time": "Configure an arrival time", @@ -74,15 +74,15 @@ } }, "departure_time": { - "title": "Choose Departure Time", + "title": "Choose departure time", "data": { - "departure_time": "Departure Time" + "departure_time": "Departure time" } }, "arrival_time": { - "title": "Choose Arrival Time", + "title": "Choose arrival time", "data": { - "arrival_time": "Arrival Time" + "arrival_time": "Arrival time" } } } From 2e8bc56be430d19a12651cef75a5468e7136a105 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 6 Feb 2025 14:46:07 -0500 Subject: [PATCH 267/288] Keep track of addons and integrations when determining HA radio firmware type (#134598) * Replace `FirmwareGuess` with `FirmwareInfo` with owner tracking * Fix up config flow * Account for OTBR addon existing independent of integration * Fix remaining unit tests * Add some tests for ownership * Unit test `get_zha_firmware_info` * ZHA `homeassistant_hardware` platform * OTBR `homeassistant_hardware` platform * Rework imports * Fix unit tests * Add OTBR unit tests * Add hassfest exemption for `homeassistant_hardware` and `otbr` * Invert registration to decouple the hardware integration * Revert "Add hassfest exemption for `homeassistant_hardware` and `otbr`" This reverts commit c8c6e7044f005239d11fc561cca040a6d89a9b39. * Fix circular imports * Fix unit tests * Address review comments * Simplify API a little * Fix `| None` mypy issues * Remove the `unregister_firmware_info_provider` API * 100% coverage * Add `HardwareInfoDispatcher.register_firmware_info_callback` * Unit test `register_firmware_info_callback` (zha) * Unit test `register_firmware_info_callback` (otbr) * Update existing hardware helper tests to use the new function * Add `async_` prefix to helper function names * Move OTBR implementation to a separate PR * Update ZHA diagnostics snapshot * Switch from `dict.setdefault` to `defaultdict` * Add some error handling to `iter_firmware_info` and increase test coverage * Oops --- .../homeassistant_hardware/__init__.py | 7 +- .../homeassistant_hardware/const.py | 13 + .../firmware_config_flow.py | 45 ++- .../homeassistant_hardware/helpers.py | 143 ++++++++ .../components/homeassistant_hardware/util.py | 172 ++++++--- .../homeassistant_sky_connect/__init__.py | 4 +- .../homeassistant_yellow/__init__.py | 4 +- homeassistant/components/zha/__init__.py | 15 +- .../components/zha/homeassistant_hardware.py | 43 +++ .../test_config_flow.py | 13 +- .../test_config_flow_failures.py | 41 ++- .../homeassistant_hardware/test_helpers.py | 185 ++++++++++ .../homeassistant_hardware/test_util.py | 336 +++++++++++------- .../homeassistant_sky_connect/test_init.py | 10 +- .../homeassistant_yellow/test_init.py | 10 +- tests/components/zha/conftest.py | 1 + .../zha/snapshots/test_diagnostics.ambr | 2 +- .../zha/test_homeassistant_hardware.py | 120 +++++++ 18 files changed, 923 insertions(+), 241 deletions(-) create mode 100644 homeassistant/components/homeassistant_hardware/helpers.py create mode 100644 homeassistant/components/zha/homeassistant_hardware.py create mode 100644 tests/components/homeassistant_hardware/test_helpers.py create mode 100644 tests/components/zha/test_homeassistant_hardware.py diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py index c33dabe1ec8..fc2b393805e 100644 --- a/homeassistant/components/homeassistant_hardware/__init__.py +++ b/homeassistant/components/homeassistant_hardware/__init__.py @@ -6,10 +6,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -DOMAIN = "homeassistant_hardware" +from .const import DATA_COMPONENT, DOMAIN +from .helpers import HardwareInfoDispatcher + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" + + hass.data[DATA_COMPONENT] = HardwareInfoDispatcher(hass) + return True diff --git a/homeassistant/components/homeassistant_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index 8fddbe41b7d..a3c091ff7ee 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -1,10 +1,23 @@ """Constants for the Homeassistant Hardware integration.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .helpers import HardwareInfoDispatcher + LOGGER = logging.getLogger(__package__) +DOMAIN = "homeassistant_hardware" +DATA_COMPONENT: HassKey[HardwareInfoDispatcher] = HassKey(DOMAIN) + ZHA_DOMAIN = "zha" +OTBR_DOMAIN = "otbr" OTBR_ADDON_NAME = "OpenThread Border Router" OTBR_ADDON_MANAGER_DATA = "openthread_border_router" diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index fac3d2d9735..8d7a302e786 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -25,12 +25,14 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.hassio import is_hassio from . import silabs_multiprotocol_addon -from .const import ZHA_DOMAIN +from .const import OTBR_DOMAIN, ZHA_DOMAIN from .util import ( ApplicationType, + OwningAddon, + OwningIntegration, get_otbr_addon_manager, - get_zha_device_path, get_zigbee_flasher_addon_manager, + guess_hardware_owners, probe_silabs_firmware_type, ) @@ -519,19 +521,15 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): ) -> ConfigFlowResult: """Pick Zigbee firmware.""" assert self._device is not None + owners = await guess_hardware_owners(self.hass, self._device) - if is_hassio(self.hass): - otbr_manager = get_otbr_addon_manager(self.hass) - otbr_addon_info = await self._async_get_addon_info(otbr_manager) - - if ( - otbr_addon_info.state != AddonState.NOT_INSTALLED - and otbr_addon_info.options.get("device") == self._device - ): - raise AbortFlow( - "otbr_still_using_stick", - description_placeholders=self._get_translation_placeholders(), - ) + for info in owners: + for owner in info.owners: + if info.source == OTBR_DOMAIN and isinstance(owner, OwningAddon): + raise AbortFlow( + "otbr_still_using_stick", + description_placeholders=self._get_translation_placeholders(), + ) return await super().async_step_pick_firmware_zigbee(user_input) @@ -541,15 +539,14 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): """Pick Thread firmware.""" assert self._device is not None - for zha_entry in self.hass.config_entries.async_entries( - ZHA_DOMAIN, - include_ignore=False, - include_disabled=True, - ): - if get_zha_device_path(zha_entry) == self._device: - raise AbortFlow( - "zha_still_using_stick", - description_placeholders=self._get_translation_placeholders(), - ) + owners = await guess_hardware_owners(self.hass, self._device) + + for info in owners: + for owner in info.owners: + if info.source == ZHA_DOMAIN and isinstance(owner, OwningIntegration): + raise AbortFlow( + "zha_still_using_stick", + description_placeholders=self._get_translation_placeholders(), + ) return await super().async_step_pick_firmware_thread(user_input) diff --git a/homeassistant/components/homeassistant_hardware/helpers.py b/homeassistant/components/homeassistant_hardware/helpers.py new file mode 100644 index 00000000000..a9b3703ee4a --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/helpers.py @@ -0,0 +1,143 @@ +"""Home Assistant Hardware integration helpers.""" + +from collections import defaultdict +from collections.abc import AsyncIterator, Awaitable, Callable +import logging +from typing import Protocol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback + +from . import DATA_COMPONENT +from .util import FirmwareInfo + +_LOGGER = logging.getLogger(__name__) + + +class SyncHardwareFirmwareInfoModule(Protocol): + """Protocol type for Home Assistant Hardware firmware info platform modules.""" + + def get_firmware_info( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> FirmwareInfo | None: + """Return radio firmware information for the config entry, synchronously.""" + + +class AsyncHardwareFirmwareInfoModule(Protocol): + """Protocol type for Home Assistant Hardware firmware info platform modules.""" + + async def async_get_firmware_info( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> FirmwareInfo | None: + """Return radio firmware information for the config entry, asynchronously.""" + + +type HardwareFirmwareInfoModule = ( + SyncHardwareFirmwareInfoModule | AsyncHardwareFirmwareInfoModule +) + + +class HardwareInfoDispatcher: + """Central dispatcher for hardware/firmware information.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the dispatcher.""" + self.hass = hass + self._providers: dict[str, HardwareFirmwareInfoModule] = {} + self._notification_callbacks: defaultdict[ + str, set[Callable[[FirmwareInfo], None]] + ] = defaultdict(set) + + def register_firmware_info_provider( + self, domain: str, platform: HardwareFirmwareInfoModule + ) -> None: + """Register a firmware info provider.""" + if domain in self._providers: + raise ValueError( + f"Domain {domain} is already registered as a firmware info provider" + ) + + # There is no need to handle "unregistration" because integrations cannot be + # wholly removed at runtime + self._providers[domain] = platform + _LOGGER.debug( + "Registered firmware info provider from domain %r: %s", domain, platform + ) + + def register_firmware_info_callback( + self, device: str, callback: Callable[[FirmwareInfo], None] + ) -> CALLBACK_TYPE: + """Register a firmware info notification callback.""" + self._notification_callbacks[device].add(callback) + + @hass_callback + def async_remove_callback() -> None: + self._notification_callbacks[device].discard(callback) + + return async_remove_callback + + async def notify_firmware_info( + self, domain: str, firmware_info: FirmwareInfo + ) -> None: + """Notify the dispatcher of new firmware information.""" + _LOGGER.debug( + "Received firmware info notification from %r: %s", domain, firmware_info + ) + + for callback in self._notification_callbacks.get(firmware_info.device, []): + try: + callback(firmware_info) + except Exception: + _LOGGER.exception( + "Error while notifying firmware info listener %s", callback + ) + + async def iter_firmware_info(self) -> AsyncIterator[FirmwareInfo]: + """Iterate over all firmware information for all hardware.""" + for domain, fw_info_module in self._providers.items(): + for config_entry in self.hass.config_entries.async_entries(domain): + try: + if hasattr(fw_info_module, "get_firmware_info"): + fw_info = fw_info_module.get_firmware_info( + self.hass, config_entry + ) + else: + fw_info = await fw_info_module.async_get_firmware_info( + self.hass, config_entry + ) + except Exception: + _LOGGER.exception( + "Error while getting firmware info from %r", fw_info_module + ) + continue + + if fw_info is not None: + yield fw_info + + +@hass_callback +def async_register_firmware_info_provider( + hass: HomeAssistant, domain: str, platform: HardwareFirmwareInfoModule +) -> None: + """Register a firmware info provider.""" + return hass.data[DATA_COMPONENT].register_firmware_info_provider(domain, platform) + + +@hass_callback +def async_register_firmware_info_callback( + hass: HomeAssistant, device: str, callback: Callable[[FirmwareInfo], None] +) -> CALLBACK_TYPE: + """Register a firmware info provider.""" + return hass.data[DATA_COMPONENT].register_firmware_info_callback(device, callback) + + +@hass_callback +def async_notify_firmware_info( + hass: HomeAssistant, domain: str, firmware_info: FirmwareInfo +) -> Awaitable[None]: + """Notify the dispatcher of new firmware information.""" + return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 3fd5bc60037..53cbcbae5d4 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -2,27 +2,27 @@ from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass from enum import StrEnum import logging -from typing import cast from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.flasher import Flasher from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton +from . import DATA_COMPONENT from .const import ( OTBR_ADDON_MANAGER_DATA, OTBR_ADDON_NAME, OTBR_ADDON_SLUG, - ZHA_DOMAIN, ZIGBEE_FLASHER_ADDON_MANAGER_DATA, ZIGBEE_FLASHER_ADDON_NAME, ZIGBEE_FLASHER_ADDON_SLUG, @@ -55,11 +55,6 @@ class ApplicationType(StrEnum): return FlasherApplicationType(self.value) -def get_zha_device_path(config_entry: ConfigEntry) -> str | None: - """Get the device path from a ZHA config entry.""" - return cast(str | None, config_entry.data.get("device", {}).get("path", None)) - - @singleton(OTBR_ADDON_MANAGER_DATA) @callback def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: @@ -84,31 +79,80 @@ def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager ) -@dataclass(slots=True, kw_only=True) -class FirmwareGuess: +@dataclass(kw_only=True) +class OwningAddon: + """Owning add-on.""" + + slug: str + + def _get_addon_manager(self, hass: HomeAssistant) -> WaitingAddonManager: + return WaitingAddonManager( + hass, + _LOGGER, + f"Add-on {self.slug}", + self.slug, + ) + + async def is_running(self, hass: HomeAssistant) -> bool: + """Check if the add-on is running.""" + addon_manager = self._get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except AddonError: + return False + else: + return addon_info.state == AddonState.RUNNING + + +@dataclass(kw_only=True) +class OwningIntegration: + """Owning integration.""" + + config_entry_id: str + + async def is_running(self, hass: HomeAssistant) -> bool: + """Check if the integration is running.""" + if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None: + return False + + return entry.state in ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, + ) + + +@dataclass(kw_only=True) +class FirmwareInfo: """Firmware guess.""" - is_running: bool + device: str firmware_type: ApplicationType + firmware_version: str | None + source: str + owners: list[OwningAddon | OwningIntegration] + + async def is_running(self, hass: HomeAssistant) -> bool: + """Check if the firmware owner is running.""" + states = await asyncio.gather(*(o.is_running(hass) for o in self.owners)) + if not states: + return False + + return all(states) -async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> FirmwareGuess: - """Guess the firmware type based on installed addons and other integrations.""" - device_guesses: defaultdict[str | None, list[FirmwareGuess]] = defaultdict(list) +async def guess_hardware_owners( + hass: HomeAssistant, device_path: str +) -> list[FirmwareInfo]: + """Guess the firmware info based on installed addons and other integrations.""" + device_guesses: defaultdict[str, list[FirmwareInfo]] = defaultdict(list) - for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN): - zha_path = get_zha_device_path(zha_config_entry) - - if zha_path is not None: - device_guesses[zha_path].append( - FirmwareGuess( - is_running=(zha_config_entry.state == ConfigEntryState.LOADED), - firmware_type=ApplicationType.EZSP, - source="zha", - ) - ) + async for firmware_info in hass.data[DATA_COMPONENT].iter_firmware_info(): + device_guesses[firmware_info.device].append(firmware_info) + # It may be possible for the OTBR addon to be present without the integration if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) @@ -119,14 +163,22 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware else: if otbr_addon_info.state != AddonState.NOT_INSTALLED: otbr_path = otbr_addon_info.options.get("device") - device_guesses[otbr_path].append( - FirmwareGuess( - is_running=(otbr_addon_info.state == AddonState.RUNNING), - firmware_type=ApplicationType.SPINEL, - source="otbr", - ) - ) + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + device_guesses[otbr_path].append( + FirmwareInfo( + device=otbr_path, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], + ) + ) + + if is_hassio(hass): multipan_addon_manager = await get_multiprotocol_addon_manager(hass) try: @@ -136,30 +188,48 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware else: if multipan_addon_info.state != AddonState.NOT_INSTALLED: multipan_path = multipan_addon_info.options.get("device") - device_guesses[multipan_path].append( - FirmwareGuess( - is_running=(multipan_addon_info.state == AddonState.RUNNING), - firmware_type=ApplicationType.CPC, - source="multiprotocol", - ) - ) - # Fall back to EZSP if we can't guess the firmware type - if device_path not in device_guesses: - return FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" + if multipan_path is not None: + device_guesses[multipan_path].append( + FirmwareInfo( + device=multipan_path, + firmware_type=ApplicationType.CPC, + firmware_version=None, + source="multiprotocol", + owners=[ + OwningAddon(slug=multipan_addon_manager.addon_slug) + ], + ) + ) + + return device_guesses.get(device_path, []) + + +async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> FirmwareInfo: + """Guess the firmware type based on installed addons and other integrations.""" + + hardware_owners = await guess_hardware_owners(hass, device_path) + + # Fall back to EZSP if we have no way to guess + if not hardware_owners: + return FirmwareInfo( + device=device_path, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], ) - # Prioritizes guesses that were pulled from a running addon or integration but keep - # the sort order we defined above - guesses = sorted( - device_guesses[device_path], - key=lambda guess: guess.is_running, - ) - + # Prioritize guesses that are pulled from a real source + guesses = [ + (guess, sum([await owner.is_running(hass) for owner in guess.owners])) + for guess in hardware_owners + ] + guesses.sort(key=lambda p: p[1]) assert guesses - return guesses[-1] + # Pick the best one. We use a stable sort so ZHA < OTBR < multi-PAN + return guesses[-1][0] async def probe_silabs_firmware_type( diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 43d42e4fa59..758f0c1e1ef 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from homeassistant.components.homeassistant_hardware.util import guess_firmware_type +from homeassistant.components.homeassistant_hardware.util import guess_firmware_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -33,7 +33,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_type( + firmware_guess = await guess_firmware_info( hass, config_entry.data["device"] ) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index dc34cc4cdc9..b0837eeedbe 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -10,7 +10,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - guess_firmware_type, + guess_firmware_info, ) from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry from homeassistant.core import HomeAssistant @@ -75,7 +75,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_type(hass, RADIO_DEVICE) + firmware_guess = await guess_firmware_info(hass, RADIO_DEVICE) new_data = {**config_entry.data} new_data[FIRMWARE] = firmware_guess.firmware_type.value diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 28f029b62d5..e446f32cf08 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,6 +12,10 @@ from zha.zigbee.device import get_device_automation_triggers from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_TYPE, @@ -25,7 +29,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType -from . import repairs, websocket_api +from . import homeassistant_hardware, repairs, websocket_api from .const import ( CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, @@ -110,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ha_zha_data = HAZHAData(yaml_config=config.get(DOMAIN, {})) hass.data[DATA_ZHA] = ha_zha_data + async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + return True @@ -218,6 +224,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_config) ) + if fw_info := homeassistant_hardware.get_firmware_info(hass, config_entry): + await async_notify_firmware_info( + hass, + DOMAIN, + firmware_info=fw_info, + ) + await ha_zha_data.gateway_proxy.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) diff --git a/homeassistant/components/zha/homeassistant_hardware.py b/homeassistant/components/zha/homeassistant_hardware.py new file mode 100644 index 00000000000..18057d3b64d --- /dev/null +++ b/homeassistant/components/zha/homeassistant_hardware.py @@ -0,0 +1,43 @@ +"""Home Assistant Hardware firmware utilities.""" + +from __future__ import annotations + +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .helpers import get_zha_gateway + + +@callback +def get_firmware_info( + hass: HomeAssistant, config_entry: ConfigEntry +) -> FirmwareInfo | None: + """Return firmware information for the ZHA instance, synchronously.""" + + # We only support EZSP firmware for now + if config_entry.data.get("radio_type", None) != "ezsp": + return None + + if (device := config_entry.data.get("device", {}).get("path")) is None: + return None + + try: + gateway = get_zha_gateway(hass) + except ValueError: + firmware_version = None + else: + firmware_version = gateway.state.node_info.version + + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.EZSP, + firmware_version=firmware_version, + source=DOMAIN, + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 145087073af..3696ea66c03 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -106,7 +107,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): @pytest.fixture(autouse=True) -def mock_test_firmware_platform( +async def mock_test_firmware_platform( hass: HomeAssistant, ) -> Generator[None]: """Fixture for a test config flow.""" @@ -116,6 +117,8 @@ def mock_test_firmware_platform( mock_integration(hass, mock_module) mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + await async_setup_component(hass, "homeassistant_hardware", {}) + with mock_config_flow(TEST_DOMAIN, FakeFirmwareConfigFlow): yield @@ -189,6 +192,10 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", return_value=mock_otbr_manager, ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=mock_otbr_manager, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager", return_value=mock_flasher_manager, @@ -197,6 +204,10 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, ), + patch( + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=is_hassio, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", return_value=app_type, diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index f5375fb51dd..c240d0198ca 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -1,6 +1,6 @@ """Test the Home Assistant hardware firmware config flow failure cases.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +9,11 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -548,21 +552,28 @@ async def test_options_flow_zigbee_to_thread_zha_configured( assert await hass.config_entries.async_setup(config_entry.entry_id) - # Set up ZHA as well - zha_config_entry = MockConfigEntry( - domain="zha", - data={"device": {"path": TEST_DEVICE}}, - ) - zha_config_entry.add_to_hass(hass) + # Pretend ZHA is using the stick + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[ + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="zha", + owners=[OwningIntegration(config_entry_id="some_config_entry_id")], + ) + ], + ): + # Confirm options flow + result = await hass.config_entries.options.async_init(config_entry.entry_id) - # Confirm options flow - result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Pick Thread + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) - # Pick Thread - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "zha_still_using_stick" diff --git a/tests/components/homeassistant_hardware/test_helpers.py b/tests/components/homeassistant_hardware/test_helpers.py new file mode 100644 index 00000000000..183995be7ce --- /dev/null +++ b/tests/components/homeassistant_hardware/test_helpers.py @@ -0,0 +1,185 @@ +"""Test hardware helpers.""" + +import logging +from unittest.mock import AsyncMock, MagicMock, Mock, call + +import pytest + +from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_callback, + async_register_firmware_info_provider, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +FIRMWARE_INFO_EZSP = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], +) + +FIRMWARE_INFO_SPINEL = FirmwareInfo( + device="/dev/serial/by-id/device2", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], +) + + +async def test_dispatcher_registration(hass: HomeAssistant) -> None: + """Test HardwareInfoDispatcher registration.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + + # Mock provider 1 with a synchronous method to pull firmware info + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + # Mock provider 2 with an asynchronous method to pull firmware info + provider2_config_entry = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id2", + data={}, + ) + provider2_config_entry.add_to_hass(hass) + provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider2_firmware = MagicMock(spec=["async_get_firmware_info"]) + provider2_firmware.async_get_firmware_info = AsyncMock( + return_value=FIRMWARE_INFO_SPINEL + ) + async_register_firmware_info_provider(hass, "otbr", provider2_firmware) + + # Double registration won't work + with pytest.raises(ValueError, match="Domain zha is already registered"): + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + # We can iterate over the results + info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()] + assert info == [ + FIRMWARE_INFO_EZSP, + FIRMWARE_INFO_SPINEL, + ] + + callback1 = Mock() + cancel1 = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device1", callback1 + ) + + callback2 = Mock() + cancel2 = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device2", callback2 + ) + + # And receive notification callbacks + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL) + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + cancel1() + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL) + cancel2() + + assert callback1.mock_calls == [ + call(FIRMWARE_INFO_EZSP), + call(FIRMWARE_INFO_EZSP), + ] + + assert callback2.mock_calls == [ + call(FIRMWARE_INFO_SPINEL), + call(FIRMWARE_INFO_SPINEL), + ] + + +async def test_dispatcher_iter_error_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test HardwareInfoDispatcher ignoring errors from firmware info providers.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(side_effect=Exception("Boom!")) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + provider2_config_entry = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id2", + data={}, + ) + provider2_config_entry.add_to_hass(hass) + provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider2_firmware = MagicMock(spec=["async_get_firmware_info"]) + provider2_firmware.async_get_firmware_info = AsyncMock( + return_value=FIRMWARE_INFO_SPINEL + ) + async_register_firmware_info_provider(hass, "otbr", provider2_firmware) + + with caplog.at_level(logging.ERROR): + info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()] + + assert info == [FIRMWARE_INFO_SPINEL] + assert "Error while getting firmware info from" in caplog.text + + +async def test_dispatcher_callback_error_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test HardwareInfoDispatcher ignoring errors from firmware info callbacks.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + callback1 = Mock(side_effect=Exception("Some error")) + async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback1) + + callback2 = Mock() + async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback2) + + with caplog.at_level(logging.ERROR): + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + + assert "Error while notifying firmware info listener" in caplog.text + + assert callback1.mock_calls == [call(FIRMWARE_INFO_EZSP)] + assert callback2.mock_calls == [call(FIRMWARE_INFO_EZSP)] diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 3f019a0409c..047de3e452c 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -1,18 +1,21 @@ """Test hardware utilities.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_provider, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, - FlasherApplicationType, - get_zha_device_path, - guess_firmware_type, - probe_silabs_firmware_type, + FirmwareInfo, + OwningAddon, + OwningIntegration, + guess_firmware_info, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -21,7 +24,21 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( unique_id="some_unique_id", data={ "device": { - "path": "socket://1.2.3.4:5678", + "path": "/dev/ttyUSB1", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, + version=4, +) + +ZHA_CONFIG_ENTRY2 = MockConfigEntry( + domain="zha", + unique_id="some_other_unique_id", + data={ + "device": { + "path": "/dev/ttyUSB2", "baudrate": 115200, "flow_control": None, }, @@ -31,153 +48,202 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( ) -def test_get_zha_device_path() -> None: - """Test extracting the ZHA device path from its config entry.""" - assert ( - get_zha_device_path(ZHA_CONFIG_ENTRY) == ZHA_CONFIG_ENTRY.data["device"]["path"] - ) - - -def test_get_zha_device_path_ignored_discovery() -> None: - """Test extracting the ZHA device path from an ignored ZHA discovery.""" - config_entry = MockConfigEntry( - domain="zha", - unique_id="some_unique_id", - data={}, - version=4, - ) - - assert get_zha_device_path(config_entry) is None - - -async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: +async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" - assert (await guess_firmware_type(hass, "/dev/missing")) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" + await async_setup_component(hass, "homeassistant_hardware", {}) + + assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo( + device="/dev/missing", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], ) -async def test_guess_firmware_type(hass: HomeAssistant) -> None: - """Test guessing the firmware.""" - path = ZHA_CONFIG_ENTRY.data["device"]["path"] +async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: + """Test guessing the firmware via OTBR and ZHA.""" - ZHA_CONFIG_ENTRY.add_to_hass(hass) + await async_setup_component(hass, "homeassistant_hardware", {}) - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.NOT_LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="zha" + # One instance of ZHA and two OTBRs + zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") + zha.add_to_hass(hass) + + otbr1 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_2") + otbr1.add_to_hass(hass) + + otbr2 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_3") + otbr2.add_to_hass(hass) + + # First ZHA is running with the stick + zha_firmware_info = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], ) - # When ZHA is running, we indicate as such when guessing - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" + # First OTBR: neither the addon or the integration are loaded + otbr_firmware_info1 = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + AsyncMock(is_running=AsyncMock(return_value=False)), + AsyncMock(is_running=AsyncMock(return_value=False)), + ], ) - mock_otbr_addon_manager = AsyncMock() - mock_multipan_addon_manager = AsyncMock() + # Second OTBR: fully running but is with an unrelated device + otbr_firmware_info2 = FirmwareInfo( + device="/dev/serial/by-id/device2", # An unrelated device + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + AsyncMock(is_running=AsyncMock(return_value=True)), + AsyncMock(is_running=AsyncMock(return_value=True)), + ], + ) - with ( - patch( - "homeassistant.components.homeassistant_hardware.util.is_hassio", - return_value=True, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", - return_value=mock_otbr_addon_manager, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", - return_value=mock_multipan_addon_manager, - ), - ): - mock_otbr_addon_manager.async_get_addon_info.side_effect = AddonError() - mock_multipan_addon_manager.async_get_addon_info.side_effect = AddonError() + mock_zha_hardware_info = MagicMock(spec=["get_firmware_info"]) + mock_zha_hardware_info.get_firmware_info = MagicMock(return_value=zha_firmware_info) + async_register_firmware_info_provider(hass, "zha", mock_zha_hardware_info) - # Hassio errors are ignored and we still go with ZHA - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) + async def mock_otbr_async_get_firmware_info( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> FirmwareInfo | None: + return { + otbr1.entry_id: otbr_firmware_info1, + otbr2.entry_id: otbr_firmware_info2, + }.get(config_entry.entry_id) - mock_otbr_addon_manager.async_get_addon_info.side_effect = None - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": "/some/other/device"}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) + mock_otbr_hardware_info = MagicMock(spec=["async_get_firmware_info"]) + mock_otbr_hardware_info.async_get_firmware_info = AsyncMock( + side_effect=mock_otbr_async_get_firmware_info + ) + async_register_firmware_info_provider(hass, "otbr", mock_otbr_hardware_info) - # We will prefer ZHA, as it is running (and actually pointing to the device) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) + # ZHA wins for the first stick, since it's actually running + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device1") + ) == zha_firmware_info - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) + # Second stick is communicating exclusively with the second OTBR + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device2") + ) == otbr_firmware_info2 - # We will still prefer ZHA, as it is the one actually running - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) - - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Finally, ZHA loses out to OTBR - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.SPINEL, source="otbr" - ) - - mock_multipan_addon_manager.async_get_addon_info.side_effect = None - mock_multipan_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Which will lose out to multi-PAN - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol" - ) + # If we stop ZHA, OTBR will take priority + zha_firmware_info.owners[0].is_running.return_value = False + otbr_firmware_info1.owners[0].is_running.return_value = True + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device1") + ) == otbr_firmware_info1 -async def test_probe_silabs_firmware_type() -> None: - """Test probing Silabs firmware type.""" +async def test_owning_addon(hass: HomeAssistant) -> None: + """Test `OwningAddon`.""" + owning_addon = OwningAddon(slug="some-addon-slug") + + # Explicitly running with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=RuntimeError, - ): - assert (await probe_silabs_firmware_type("/dev/ttyUSB0")) is None - - with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=lambda self: setattr(self, "app_type", FlasherApplicationType.EZSP), - autospec=True, - ) as mock_probe_app_type: - # The application type constant is converted back and forth transparently - result = await probe_silabs_firmware_type( - "/dev/ttyUSB0", probe_methods=[ApplicationType.EZSP] + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) ) - assert result is ApplicationType.EZSP + assert (await owning_addon.is_running(hass)) is True - flasher = mock_probe_app_type.mock_calls[0].args[0] - assert flasher._probe_methods == [FlasherApplicationType.EZSP] + # Explicitly not running + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + ) + assert (await owning_addon.is_running(hass)) is False + + # Failed to get status + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + side_effect=AddonError() + ) + assert (await owning_addon.is_running(hass)) is False + + +async def test_owning_integration(hass: HomeAssistant) -> None: + """Test `OwningIntegration`.""" + config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") + config_entry.add_to_hass(hass) + + owning_integration = OwningIntegration(config_entry_id=config_entry.entry_id) + + # Explicitly running + config_entry.mock_state(hass, ConfigEntryState.LOADED) + assert (await owning_integration.is_running(hass)) is True + + # Explicitly not running + config_entry.mock_state(hass, ConfigEntryState.NOT_LOADED) + assert (await owning_integration.is_running(hass)) is False + + # Missing config entry + owning_integration2 = OwningIntegration(config_entry_id="some_nonexistenct_id") + assert (await owning_integration2.is_running(hass)) is False + + +async def test_firmware_info(hass: HomeAssistant) -> None: + """Test `FirmwareInfo`.""" + + owner1 = AsyncMock() + owner2 = AsyncMock() + + firmware_info = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="zha", + owners=[owner1, owner2], + ) + + # Both running + owner1.is_running.return_value = True + owner2.is_running.return_value = True + assert (await firmware_info.is_running(hass)) is True + + # Only one running + owner1.is_running.return_value = True + owner2.is_running.return_value = False + assert (await firmware_info.is_running(hass)) is False + + # No owners + firmware_info2 = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="zha", + owners=[], + ) + + assert (await firmware_info2.is_running(hass)) is False diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 15eeb205537..8e90039a4fc 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant @@ -32,11 +32,13 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( - "homeassistant.components.homeassistant_sky_connect.guess_firmware_type", - return_value=FirmwareGuess( - is_running=True, + "homeassistant.components.homeassistant_sky_connect.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + firmware_version=None, firmware_type=ApplicationType.SPINEL, source="otbr", + owners=[], ), ): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 5d534dad1e7..57d63c7441e 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import zha from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -49,11 +49,13 @@ async def test_setup_entry( return_value=onboarded, ), patch( - "homeassistant.components.homeassistant_yellow.guess_firmware_type", - return_value=FirmwareGuess( # Nothing is setup - is_running=False, + "homeassistant.components.homeassistant_yellow.guess_firmware_info", + return_value=FirmwareInfo( # Nothing is setup + device="/dev/ttyAMA1", + firmware_version=None, firmware_type=ApplicationType.EZSP, source="unknown", + owners=[], ), ), ): diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 78d335469b8..96a61a6628b 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -155,6 +155,7 @@ async def zigpy_app_controller(): app.state.node_info.ieee = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") app.state.node_info.manufacturer = "Coordinator Manufacturer" app.state.node_info.model = "Coordinator Model" + app.state.node_info.version = "7.1.4.0 build 389" app.state.network_info.pan_id = 0x1234 app.state.network_info.extended_pan_id = app.state.node_info.ieee app.state.network_info.channel = 15 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index f46a06e84b8..c9a5e80b1c9 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -75,7 +75,7 @@ 'manufacturer': 'Coordinator Manufacturer', 'model': 'Coordinator Model', 'nwk': 0, - 'version': None, + 'version': '7.1.4.0 build 389', }), }), 'config': dict({ diff --git a/tests/components/zha/test_homeassistant_hardware.py b/tests/components/zha/test_homeassistant_hardware.py new file mode 100644 index 00000000000..72285521182 --- /dev/null +++ b/tests/components/zha/test_homeassistant_hardware.py @@ -0,0 +1,120 @@ +"""Test Home Assistant Hardware platform for ZHA.""" + +from unittest.mock import MagicMock, patch + +import pytest +from zigpy.application import ControllerApplication + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.components.zha.homeassistant_hardware import get_firmware_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_get_firmware_info_normal(hass: HomeAssistant) -> None: + """Test `get_firmware_info`.""" + + zha = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data={ + "device": { + "path": "/dev/ttyUSB1", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, + version=4, + ) + zha.add_to_hass(hass) + zha.mock_state(hass, ConfigEntryState.LOADED) + + # With ZHA running + with patch( + "homeassistant.components.zha.homeassistant_hardware.get_zha_gateway" + ) as mock_get_zha_gateway: + mock_get_zha_gateway.return_value.state.node_info.version = "1.2.3.4" + fw_info_running = get_firmware_info(hass, zha) + + assert fw_info_running == FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="zha", + owners=[OwningIntegration(config_entry_id=zha.entry_id)], + ) + assert await fw_info_running.is_running(hass) is True + + # With ZHA not running + zha.mock_state(hass, ConfigEntryState.NOT_LOADED) + fw_info_not_running = get_firmware_info(hass, zha) + + assert fw_info_not_running == FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[OwningIntegration(config_entry_id=zha.entry_id)], + ) + assert await fw_info_not_running.is_running(hass) is False + + +@pytest.mark.parametrize( + "data", + [ + # Missing data + {}, + # Bad radio type + {"device": {"path": "/dev/ttyUSB1"}, "radio_type": "znp"}, + ], +) +async def test_get_firmware_info_errors( + hass: HomeAssistant, data: dict[str, str | int | None] +) -> None: + """Test `get_firmware_info` with config entry data format errors.""" + zha = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data=data, + version=4, + ) + zha.add_to_hass(hass) + + assert (get_firmware_info(hass, zha)) is None + + +async def test_hardware_firmware_info_provider_notification( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, +) -> None: + """Test that the ZHA gateway provides hardware and firmware information.""" + config_entry.add_to_hass(hass) + + await async_setup_component(hass, "homeassistant_hardware", {}) + + callback = MagicMock() + async_register_firmware_info_callback(hass, "/dev/ttyUSB0", callback) + + await hass.config_entries.async_setup(config_entry.entry_id) + + callback.assert_called_once_with( + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version="7.1.4.0 build 389", + source="zha", + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + ) + ) From d3d00357aa76e54b7d2d4954f1976d432137f1c3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 6 Feb 2025 21:11:39 +0100 Subject: [PATCH 268/288] Allow to omit the payload attribute to MQTT publish action to allow an empty payload to be sent by default (#137595) Allow to omit the payload attribute to MQTT publish actionto allow an empty payload to be sent by default --- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mqtt/services.yaml | 1 - homeassistant/components/mqtt/strings.json | 6 +----- tests/components/mqtt/test_init.py | 19 +++++++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8b16e9fa53d..6656afe2c8a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -236,7 +236,7 @@ CONFIG_SCHEMA = vol.Schema( MQTT_PUBLISH_SCHEMA = vol.Schema( { vol.Required(ATTR_TOPIC): valid_publish_topic, - vol.Required(ATTR_PAYLOAD): cv.string, + vol.Required(ATTR_PAYLOAD, default=None): vol.Any(cv.string, None), vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index c5e4f372bd6..f6fac1d2c1e 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -8,7 +8,6 @@ publish: selector: text: payload: - required: true example: "The temperature is {{ states('sensor.temperature') }}" selector: template: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index bf0bd594ea4..fc316306d56 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -238,11 +238,7 @@ }, "payload": { "name": "Payload", - "description": "The payload to publish." - }, - "payload_template": { - "name": "Payload template", - "description": "Template to render as a payload value. If a payload is provided, the template is ignored." + "description": "The payload to publish. Publishes an empty message if not provided." }, "qos": { "name": "QoS", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d05c340dac2..b2dd3d048ec 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -391,6 +391,25 @@ async def test_service_call_with_ascii_qos_retain_flags( blocking=True, ) assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "" + assert mqtt_mock.async_publish.call_args[0][2] == 2 + assert not mqtt_mock.async_publish.call_args[0][3] + + mqtt_mock.reset_mock() + + # Test service call without payload + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_QOS: "2", + mqtt.ATTR_RETAIN: "no", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] is None assert mqtt_mock.async_publish.call_args[0][2] == 2 assert not mqtt_mock.async_publish.call_args[0][3] From d2d7d696ec01ee04e5089cb72358bcd8b410f10f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 6 Feb 2025 21:34:11 +0100 Subject: [PATCH 269/288] Bump `aioshelly` to version `12.4.1` (#137598) * Bump aioshelly to 12.4.0 * Bump to 12.4.1 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index e0d8c03ffc4..4cfb49b680f 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.3.2"], + "requirements": ["aioshelly==12.4.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 432f30c5966..f19f50412c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.2 +aioshelly==12.4.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d21e044bd89..9f02212b213 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.2 +aioshelly==12.4.1 # homeassistant.components.skybell aioskybell==22.7.0 From c8bafe9c461e4be6e3416514f1258c10e6f26f84 Mon Sep 17 00:00:00 2001 From: Jan Rieger <271149+jrieger@users.noreply.github.com> Date: Thu, 6 Feb 2025 21:36:58 +0100 Subject: [PATCH 270/288] Remove deprecated state attributes from GPSd (#137600) --- homeassistant/components/gpsd/sensor.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 70d32f88a65..86d3ab7cc04 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import logging -from typing import Any from gps3.agps3threaded import AGPS3mechanism @@ -38,7 +37,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_CLIMB = "climb" ATTR_ELEVATION = "elevation" -ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" ATTR_TOTAL_SATELLITES = "total_satellites" ATTR_USED_SATELLITES = "used_satellites" @@ -201,21 +199,3 @@ class GpsdSensor(SensorEntity): """Return the state of GPSD.""" value = self.entity_description.value_fn(self.agps_thread) return None if value == "n/a" else value - - # Deprecated since Home Assistant 2024.9.0 - # Can be removed completely in 2025.3.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the GPS.""" - if self.entity_description.key != ATTR_MODE: - return None - - return { - ATTR_LATITUDE: self.agps_thread.data_stream.lat, - ATTR_LONGITUDE: self.agps_thread.data_stream.lon, - ATTR_ELEVATION: self.agps_thread.data_stream.alt, - ATTR_GPS_TIME: self.agps_thread.data_stream.time, - ATTR_SPEED: self.agps_thread.data_stream.speed, - ATTR_CLIMB: self.agps_thread.data_stream.climb, - ATTR_MODE: self.agps_thread.data_stream.mode, - } From 609188bb33874dfda541ad6ec7d376e161e6f138 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 7 Feb 2025 10:47:53 +1300 Subject: [PATCH 271/288] Bump electrickiwi-api to 0.9.13 (#137601) * bump ek api version to fix deps * Revert "Skip building wheels for electrickiwi-api (#137556)" This reverts commit 5f6068eea4b23d4b8100de0830ee06532638524f. --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../components/electric_kiwi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 17 +++++------------ 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 9afe487d368..1d4e26d5e0d 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.9.12"] + "requirements": ["electrickiwi-api==0.9.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index f19f50412c5..2d4c8a58132 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.12 +electrickiwi-api==0.9.13 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f02212b213..19fd33ea9f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -702,7 +702,7 @@ easyenergy==2.1.2 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.12 +electrickiwi-api==0.9.13 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 107d202cfdb..fa823fa4834 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,12 +50,6 @@ INCLUDED_REQUIREMENTS_WHEELS = { "pyuserinput", } -EXCLUDED_REQUIREMENTS_WHEELS = { - # Exclude 'electrickiwi-api' temporarily, until <3.13 pin is removed upstream. - # https://github.com/mikey0000/EK-API/pull/1 - "electrickiwi-api", -} - # Requirements to exclude or include when running github actions. # Requirements listed in "exclude" will be commented-out in @@ -70,7 +64,7 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { "markers": {}, }, "wheels_aarch64": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, @@ -79,23 +73,22 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. "wheels_armhf": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS - | {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, + "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_armv7": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_amd64": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_i386": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, From 8a08a87a25fa63cd26d2992bd893319498932b50 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 6 Feb 2025 23:24:09 +0100 Subject: [PATCH 272/288] KNX tests: Support loading different config store fixtures (#136961) * Support loading different config store fixtures * Add config store test for binary sensor * Update README.md * remove unused fixture * AddAdd config store test for switch --- tests/components/knx/README.md | 11 ++++-- tests/components/knx/conftest.py | 36 ++++++++++++------- .../fixtures/config_store_binarysensor.json | 27 ++++++++++++++ ...re.json => config_store_light_switch.json} | 0 tests/components/knx/test_binary_sensor.py | 9 ++++- tests/components/knx/test_config_flow.py | 2 +- tests/components/knx/test_config_store.py | 18 +++++----- tests/components/knx/test_device.py | 5 ++- tests/components/knx/test_device_trigger.py | 14 ++++---- tests/components/knx/test_diagnostic.py | 13 ++++--- tests/components/knx/test_init.py | 6 ++-- tests/components/knx/test_interface_device.py | 6 ++-- tests/components/knx/test_light.py | 10 +++--- tests/components/knx/test_services.py | 12 +++---- tests/components/knx/test_switch.py | 15 +++++++- tests/components/knx/test_telegrams.py | 6 ++-- tests/components/knx/test_trigger.py | 10 +++--- tests/components/knx/test_websocket.py | 20 +++++------ 18 files changed, 142 insertions(+), 78 deletions(-) create mode 100644 tests/components/knx/fixtures/config_store_binarysensor.json rename tests/components/knx/fixtures/{config_store.json => config_store_light_switch.json} (100%) diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index ef8398b3d17..71218010b45 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -3,17 +3,22 @@ A KNXTestKit instance can be requested from a fixture. It provides convenience methods to test outgoing KNX telegrams and inject incoming telegrams. To test something add a test function requesting the `hass` and `knx` fixture and -set up the KNX integration by passing a KNX config dict to `knx.setup_integration`. +set up the KNX integration with `knx.setup_integration`. +You can pass a KNX YAML-config dict or a ConfigStore fixture filename to the setup method. The fixture should be placed in the `tests/components/knx/fixtures` directory. ```python -async def test_something(hass, knx): - await knx.setup_integration({ +async def test_some_yaml(hass: HomeAssistant, knx: KNXTestKit): + await knx.setup_integration( + yaml_config={ "switch": { "name": "test_switch", "address": "1/2/3", } } ) + +async def test_some_config_store(hass: HomeAssistant, knx: KNXTestKit): + await knx.setup_integration(config_store_fixture="config_store_filename.json") ``` ## Asserting outgoing telegrams diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 4e50836bb79..c9092a1774f 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -44,7 +44,6 @@ from tests.common import MockConfigEntry, load_json_object_fixture from tests.typing import WebSocketGenerator FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", KNX_DOMAIN) -FIXTURE_CONFIG_STORAGE_DATA = load_json_object_fixture("config_store.json", KNX_DOMAIN) class KNXTestKit: @@ -52,10 +51,16 @@ class KNXTestKit: INDIVIDUAL_ADDRESS = "1.2.3" - def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], + ) -> None: """Init KNX test helper class.""" self.hass: HomeAssistant = hass self.mock_config_entry: MockConfigEntry = mock_config_entry + self.hass_storage: dict[str, Any] = hass_storage self.xknx: XKNX # outgoing telegrams will be put in the List instead of sent to the interface # telegrams to an InternalGroupAddress won't be queued here @@ -69,7 +74,10 @@ class KNXTestKit: assert test_state.attributes.get(attribute) == value async def setup_integration( - self, config: ConfigType, add_entry_to_hass: bool = True + self, + yaml_config: ConfigType | None = None, + config_store_fixture: str | None = None, + add_entry_to_hass: bool = True, ) -> None: """Create the KNX integration.""" @@ -101,15 +109,21 @@ class KNXTestKit: self.xknx = args[0] return DEFAULT + if config_store_fixture: + self.hass_storage[KNX_CONFIG_STORAGE_KEY] = load_json_object_fixture( + config_store_fixture, KNX_DOMAIN + ) + if add_entry_to_hass: self.mock_config_entry.add_to_hass(self.hass) + knx_config = {KNX_DOMAIN: yaml_config or {}} with patch( "xknx.xknx.knx_interface_factory", return_value=knx_ip_interface_mock(), side_effect=fish_xknx, ): - await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) + await async_setup_component(self.hass, KNX_DOMAIN, knx_config) await self.hass.async_block_till_done() ######################## @@ -306,9 +320,13 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -async def knx(hass: HomeAssistant, mock_config_entry: MockConfigEntry): +async def knx( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +): """Create a KNX TestKit instance.""" - knx_test_kit = KNXTestKit(hass, mock_config_entry) + knx_test_kit = KNXTestKit(hass, mock_config_entry, hass_storage) yield knx_test_kit await knx_test_kit.assert_no_telegram() @@ -322,12 +340,6 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: } -@pytest.fixture -def load_config_store(hass_storage: dict[str, Any]) -> None: - """Mock KNX config store data.""" - hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA - - @pytest.fixture async def create_ui_entity( hass: HomeAssistant, diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json new file mode 100644 index 00000000000..427867cff8c --- /dev/null +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": {}, + "binary_sensor": { + "knx_es_01JJP1XDQRXB0W6YYGXW6Y1X10": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_sensor": { + "state": "3/2/21", + "passive": [] + }, + "respond_to_read": false, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store_light_switch.json similarity index 100% rename from tests/components/knx/fixtures/config_store.json rename to tests/components/knx/fixtures/config_store_light_switch.json diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 4b58801a8a0..b93b7e965df 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -329,7 +329,7 @@ async def test_binary_sensor_ui_create( knx_data: dict[str, Any], ) -> None: """Test creating a binary sensor.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.BINARY_SENSOR, entity_data={"name": "test"}, @@ -340,3 +340,10 @@ async def test_binary_sensor_ui_create( await knx.receive_response("2/2/2", not knx_data.get("invert")) state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON + + +async def test_binary_sensor_ui_load(knx: KNXTestKit) -> None: + """Test loading a binary sensor from storage.""" + await knx.setup_integration(config_store_fixture="config_store_binarysensor.json") + await knx.assert_read("3/2/21", response=True, ignore_order=True) + knx.assert_state("binary_sensor.test", STATE_ON) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 8ed79f837bb..3e4c9408542 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1278,7 +1278,7 @@ async def test_options_flow_connection_type( # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) - await knx.setup_integration({}) + await knx.setup_integration() menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) with patch( diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 116f4b5d839..aee0a4036ff 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -25,7 +25,7 @@ async def test_create_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_name = "Test no device" @@ -69,7 +69,7 @@ async def test_create_entity_error( hass_ws_client: WebSocketGenerator, ) -> None: """Test unsuccessful entity creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) # create entity with invalid platform @@ -116,7 +116,7 @@ async def test_update_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -163,7 +163,7 @@ async def test_update_entity_error( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -238,7 +238,7 @@ async def test_delete_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity deletion.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -270,7 +270,7 @@ async def test_delete_entity_error( hass_storage: dict[str, Any], ) -> None: """Test unsuccessful entity deletion.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) # delete unknown entity @@ -307,7 +307,7 @@ async def test_get_entity_config( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity config retrieval.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -355,7 +355,7 @@ async def test_get_entity_config_error( error_message_start: str, ) -> None: """Test entity config retrieval errors.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -376,7 +376,7 @@ async def test_validate_entity( hass_ws_client: WebSocketGenerator, ) -> None: """Test entity validation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index 04ff02f0611..356640dd8d0 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -22,7 +22,7 @@ async def test_create_device( hass_ws_client: WebSocketGenerator, ) -> None: """Test device creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -50,12 +50,11 @@ async def test_remove_device( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - load_config_store: None, hass_storage: dict[str, Any], ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) - await knx.setup_integration({}) + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") client = await hass_ws_client(hass) await knx.assert_read("1/0/21", response=True, ignore_order=True) # test light diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index e5f776a9404..e4a208906c6 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -28,7 +28,7 @@ async def test_if_fires_on_telegram( knx: KNXTestKit, ) -> None: """Test telegram device triggers firing.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -124,7 +124,7 @@ async def test_default_if_fires_on_telegram( # by default (without a user changing any) extra_fields are not added to the trigger and # pre 2024.2 device triggers did only support "destination" field so they didn't have # "group_value_write", "group_value_response", "group_value_read", "incoming", "outgoing" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -206,7 +206,7 @@ async def test_remove_device_trigger( ) -> None: """Test for removed callback when device trigger not used.""" automation_name = "telegram_trigger_automation" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -256,7 +256,7 @@ async def test_get_triggers( knx: KNXTestKit, ) -> None: """Test we get the expected device triggers from knx.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -279,7 +279,7 @@ async def test_get_trigger_capabilities( knx: KNXTestKit, ) -> None: """Test we get the expected capabilities telegram device trigger.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -361,7 +361,7 @@ async def test_invalid_device_trigger( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid telegram device trigger configuration.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -404,7 +404,7 @@ async def test_invalid_trigger_configuration( knx: KNXTestKit, ) -> None: """Test invalid telegram device trigger configuration at attach_trigger.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index bb60e66f7e7..6d4bf7e6007 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the KNX integration.""" +from typing import Any + import pytest from syrupy import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT @@ -40,7 +42,7 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -60,7 +62,7 @@ async def test_diagnostic_config_error( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -76,6 +78,7 @@ async def test_diagnostic_config_error( async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test diagnostics redacting data.""" @@ -95,8 +98,8 @@ async def test_diagnostic_redact( CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, ) - knx: KNXTestKit = KNXTestKit(hass, mock_config_entry) - await knx.setup_integration({}) + knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -117,7 +120,7 @@ async def test_diagnostics_project( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() knx.xknx.version = "0.0.0" # snapshot will contain project specific fields in `project_info` assert ( diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 75cd5d1eb21..579f9b143a2 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -226,7 +226,7 @@ async def test_init_connection_handling( data=config_entry_data, ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() assert hass.data.get(KNX_DOMAIN) is not None @@ -280,7 +280,7 @@ async def _init_switch_and_wait_for_first_state_updater_run( title="KNX", domain=KNX_DOMAIN, data=config_entry_data ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.SWITCH, knx_data={ @@ -354,7 +354,7 @@ async def test_async_remove_entry( }, ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() with ( patch("pathlib.Path.unlink") as unlink_mock, diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 79114d4ffd5..4de366c69f0 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -25,7 +25,7 @@ async def test_diagnostic_entities( freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostic entities.""" - await knx.setup_integration({}) + await knx.setup_integration() for entity_id in ( "sensor.knx_interface_individual_address", @@ -103,7 +103,7 @@ async def test_removed_entity( with patch( "xknx.core.connection_manager.ConnectionManager.unregister_connection_state_changed_cb" ) as unregister_mock: - await knx.setup_integration({}) + await knx.setup_integration() entity_registry.async_update_entity( "sensor.knx_interface_connection_established", @@ -120,7 +120,7 @@ async def test_remove_interface_device( ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) knx_devices = device_registry.devices.get_devices_for_config_entry_id( knx.mock_config_entry.entry_id diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 6ba6090d60d..fb0246763a4 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1176,7 +1176,7 @@ async def test_light_ui_create( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a light.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1213,7 +1213,7 @@ async def test_light_ui_color_temp( raw_ct: tuple[int, ...], ) -> None: """Test creating a color-temp light.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1250,7 +1250,7 @@ async def test_light_ui_multi_mode( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a light with multiple color modes.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1335,13 +1335,11 @@ async def test_light_ui_multi_mode( async def test_light_ui_load( - hass: HomeAssistant, knx: KNXTestKit, - load_config_store: None, entity_registry: er.EntityRegistry, ) -> None: """Test loading a light from storage.""" - await knx.setup_integration({}) + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") await knx.assert_read("1/0/21", response=True, ignore_order=True) # unrelated switch in config store diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index f70389dbc92..c4b48b5e81d 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -111,7 +111,7 @@ async def test_send( expected_apci, ) -> None: """Test `knx.send` service.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.services.async_call( "knx", @@ -127,7 +127,7 @@ async def test_send( async def test_read(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test `knx.read` service.""" - await knx.setup_integration({}) + await knx.setup_integration() # send read telegram await hass.services.async_call("knx", "read", {"address": "1/1/1"}, blocking=True) @@ -150,7 +150,7 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: events = async_capture_events(hass, "knx_event") test_address = "1/2/3" - await knx.setup_integration({}) + await knx.setup_integration() # no event registered await knx.receive_write(test_address, True) @@ -200,7 +200,7 @@ async def test_exposure_register(hass: HomeAssistant, knx: KNXTestKit) -> None: test_entity = "fake.entity" test_attribute = "fake_attribute" - await knx.setup_integration({}) + await knx.setup_integration() # no exposure registered hass.states.async_set(test_entity, STATE_ON, {}) @@ -265,7 +265,7 @@ async def test_reload_service( knx: KNXTestKit, ) -> None: """Test reload service.""" - await knx.setup_integration({}) + await knx.setup_integration() with ( patch( @@ -285,7 +285,7 @@ async def test_reload_service( async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test service setup failed.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) with pytest.raises(HomeAssistantError) as exc_info: diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index bc0a6b27675..969c11b8e1a 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -155,7 +155,7 @@ async def test_switch_ui_create( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a switch.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.SWITCH, entity_data={"name": "test"}, @@ -171,3 +171,16 @@ async def test_switch_ui_create( await knx.receive_response("2/2/2", True) state = hass.states.get("switch.test") assert state.state is STATE_ON + + +async def test_switch_ui_load(knx: KNXTestKit) -> None: + """Test loading a switch from storage.""" + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") + + await knx.assert_read("1/0/45", response=True, ignore_order=True) + # unrelated light in config store + await knx.assert_read("1/0/21", response=True, ignore_order=True) + knx.assert_state( + "switch.none_test", # has_entity_name with unregistered device -> none_test + STATE_ON, + ) diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 883e8ccbb2d..840959bb6c5 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -70,7 +70,7 @@ async def test_store_telegam_history( hass_storage: dict[str, Any], ) -> None: """Test storing telegram history.""" - await knx.setup_integration({}) + await knx.setup_integration() await knx.receive_write("1/3/4", True) await hass.services.async_call( @@ -94,7 +94,7 @@ async def test_load_telegam_history( ) -> None: """Test telegram history restoration.""" hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} - await knx.setup_integration({}) + await knx.setup_integration() loaded_telegrams = hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams assert assert_telegram_history(loaded_telegrams) # TelegramDict "payload" is a tuple, this shall be restored when loading from JSON @@ -113,7 +113,7 @@ async def test_remove_telegam_history( knx.mock_config_entry, data=knx.mock_config_entry.data | {CONF_KNX_TELEGRAM_LOG_SIZE: 0}, ) - await knx.setup_integration({}, add_entry_to_hass=False) + await knx.setup_integration(add_entry_to_hass=False) # Store.async_remove() is mocked by hass_storage - check that data was removed. assert "knx/telegrams_history.json" not in hass_storage assert not hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams diff --git a/tests/components/knx/test_trigger.py b/tests/components/knx/test_trigger.py index 73e8b10840e..1ce42a23482 100644 --- a/tests/components/knx/test_trigger.py +++ b/tests/components/knx/test_trigger.py @@ -18,7 +18,7 @@ async def test_telegram_trigger( knx: KNXTestKit, ) -> None: """Test telegram triggers firing.""" - await knx.setup_integration({}) + await knx.setup_integration() # "id" field added to action to test if `trigger_data` passed correctly in `async_attach_trigger` assert await async_setup_component( @@ -105,7 +105,7 @@ async def test_telegram_trigger_dpt_option( expected_unit: str | None, ) -> None: """Test telegram trigger type option.""" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, automation.DOMAIN, @@ -190,7 +190,7 @@ async def test_telegram_trigger_options( direction_options: dict[str, bool], ) -> None: """Test telegram trigger options.""" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, automation.DOMAIN, @@ -266,7 +266,7 @@ async def test_remove_telegram_trigger( ) -> None: """Test for removed callback when telegram trigger not used.""" automation_name = "telegram_trigger_automation" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, @@ -311,7 +311,7 @@ async def test_invalid_trigger( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid telegram trigger configuration.""" - await knx.setup_integration({}) + await knx.setup_integration() caplog.clear() with caplog.at_level(logging.ERROR): assert await async_setup_component( diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index a34f126e4f4..7054d415ee9 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -20,7 +20,7 @@ async def test_knx_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/info command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/info"}) @@ -39,7 +39,7 @@ async def test_knx_info_command_with_project( load_knxproj: None, ) -> None: """Test knx/info command with loaded project.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/info"}) @@ -65,7 +65,7 @@ async def test_knx_project_file_process( _password = "pw-test" _parse_result = FIXTURE_PROJECT_DATA - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded @@ -100,7 +100,7 @@ async def test_knx_project_file_process_error( hass_ws_client: WebSocketGenerator, ) -> None: """Test knx/project_file_process exception handling.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded @@ -134,7 +134,7 @@ async def test_knx_project_file_remove( hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" - await knx.setup_integration({}) + await knx.setup_integration() assert hass_storage[KNX_PROJECT_STORAGE_KEY] client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded @@ -154,7 +154,7 @@ async def test_knx_get_project( load_knxproj: None, ) -> None: """Test retrieval of kxnproject from store.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded @@ -169,7 +169,7 @@ async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_monitor_info command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) @@ -184,7 +184,7 @@ async def test_knx_group_telegrams_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_telegrams command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "knx/group_telegrams"}) @@ -338,7 +338,7 @@ async def test_knx_subscribe_telegrams_command_project( load_knxproj: None, ) -> None: """Test knx/subscribe_telegrams command with project data.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) res = await client.receive_json() @@ -405,7 +405,7 @@ async def test_websocket_when_config_entry_unloaded( endpoint: str, ) -> None: """Test websocket connection when config entry is unloaded.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) client = await hass_ws_client(hass) From fd6e2a6e196b552f84e4c68c420b83d140e88c45 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 6 Feb 2025 23:37:26 +0100 Subject: [PATCH 273/288] Add Wi-Fi RSSI sensor in HomeWizard (#136754) --- homeassistant/components/homewizard/sensor.py | 16 + .../components/homewizard/strings.json | 3 + .../homewizard/snapshots/test_sensor.ambr | 336 ++++++++++++++++++ tests/components/homewizard/test_sensor.py | 13 + 4 files changed, 368 insertions(+) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 582c65f2838..f6f5588956c 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_VIA_DEVICE, PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -137,6 +138,21 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( else None ), ), + HomeWizardSensorEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=( + lambda data: data.system is not None + and data.system.wifi_rssi_db is not None + ), + value_fn=( + lambda data: data.system.wifi_rssi_db if data.system is not None else None + ), + ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", translation_key="total_energy_import_kwh", diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 02b18d5fa4e..076e9375d24 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -78,6 +78,9 @@ "wifi_strength": { "name": "Wi-Fi strength" }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, "total_energy_import_kwh": { "name": "Energy import" }, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 692383b4794..91b1e30e4f8 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -782,6 +782,174 @@ 'state': '230.0', }) # --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_rssi', + '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': 'Wi-Fi RSSI', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_rssi', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi RSSI', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-77', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + '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': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'simulating v1 support', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -14363,6 +14531,174 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + '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': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + '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': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 94a59551eb4..fe709570239 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -108,6 +108,8 @@ pytestmark = [ "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", ], ), ( @@ -304,6 +306,8 @@ pytestmark = [ "sensor.device_state_of_charge", "sensor.device_uptime", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", + "sensor.device_wi_fi_ssid", ], ), ], @@ -453,6 +457,7 @@ async def test_sensors( "sensor.device_frequency", "sensor.device_uptime", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", ], ), ], @@ -561,6 +566,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -610,6 +616,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -667,6 +674,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -718,6 +726,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -758,6 +767,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -809,6 +819,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -849,6 +860,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -897,6 +909,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_strength", ], ), ], From a0b8ad16baa9c43845974c4a82efdbc60919a5e5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:41:09 -0600 Subject: [PATCH 274/288] Add remaining HEOS exception translations (#136878) Add coordinator exc translations --- homeassistant/components/heos/coordinator.py | 12 ++++++++++-- homeassistant/components/heos/quality_scale.yaml | 2 +- homeassistant/components/heos/strings.json | 6 ++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index dc8989fd55b..94aa4ad0ab5 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -82,12 +82,20 @@ class HeosCoordinator(DataUpdateCoordinator[None]): try: await self.heos.connect() except HeosError as error: - raise ConfigEntryNotReady from error + _LOGGER.debug("Unable to connect to %s", self.host, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="unable_to_connect", + translation_placeholders={"host": self.host}, + ) from error # Load players try: await self.heos.get_players() except HeosError as error: - raise ConfigEntryNotReady from error + _LOGGER.debug("Unexpected error retrieving players", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="unable_to_get_players" + ) from error if not self.heos.is_signed_in: _LOGGER.warning( diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index f5066d0a743..67022ec492c 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -54,7 +54,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: todo diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 4092d4360db..53e20a032b5 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -112,6 +112,12 @@ "not_heos_media_player": { "message": "Entity {entity_id} is not a HEOS media player entity" }, + "unable_to_connect": { + "message": "Unable to connect to {host}" + }, + "unable_to_get_players": { + "message": "Unexpected error retrieving players" + }, "unknown_source": { "message": "Unknown source: {source}" } From c89b416f85af1c1276a9ad7dfd2265f441022ae8 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 7 Feb 2025 01:33:07 +0100 Subject: [PATCH 275/288] Bump ZHA to 0.0.48 (#137610) --- 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 6a42bc986e9..821159afb22 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.47"], + "requirements": ["zha==0.0.48"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 2d4c8a58132..10abbbd380d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3134,7 +3134,7 @@ zeroconf==0.143.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.47 +zha==0.0.48 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19fd33ea9f1..f0ab40dc5a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2523,7 +2523,7 @@ zeroconf==0.143.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.47 +zha==0.0.48 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From f2e6231aa24d44d9ce11974336d3e41af246b7df Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 7 Feb 2025 05:12:02 +0100 Subject: [PATCH 276/288] Remove redundant dependency markers (#137608) --- homeassistant/package_constraints.txt | 6 +++--- pyproject.toml | 6 +++--- requirements.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 85d6ba2c84c..05c05d93548 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ async-interrupt==1.2.1 async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 -audioop-lts==0.2.1;python_version>='3.13' +audioop-lts==0.2.1 av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 @@ -62,8 +62,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 SQLAlchemy==2.0.37 -standard-aifc==3.13.0;python_version>='3.13' -standard-telnetlib==3.13.0;python_version>='3.13' +standard-aifc==3.13.0 +standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 diff --git a/pyproject.toml b/pyproject.toml index ab927b21b7c..c6c506dbff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "async-interrupt==1.2.1", "attrs==25.1.0", "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1;python_version>='3.13'", + "audioop-lts==0.2.1", "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", @@ -68,8 +68,8 @@ dependencies = [ "requests==2.32.3", "securetar==2025.1.4", "SQLAlchemy==2.0.37", - "standard-aifc==3.13.0;python_version>='3.13'", - "standard-telnetlib==3.13.0;python_version>='3.13'", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", "ulid-transform==1.2.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 diff --git a/requirements.txt b/requirements.txt index 5e5fabb5723..bd54a380fd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ astral==2.2 async-interrupt==1.2.1 attrs==25.1.0 atomicwrites-homeassistant==1.4.1 -audioop-lts==0.2.1;python_version>='3.13' +audioop-lts==0.2.1 awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 @@ -40,8 +40,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 SQLAlchemy==2.0.37 -standard-aifc==3.13.0;python_version>='3.13' -standard-telnetlib==3.13.0;python_version>='3.13' +standard-aifc==3.13.0 +standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 From 0e0bc4bfe9395d8100408aa1a6363ef24680bd7c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 7 Feb 2025 00:33:58 -0800 Subject: [PATCH 277/288] Update google-nest-sdm to 7.1.3 (#137625) * Update google-nest-sdm to 7.1.2 * Bump nest to 7.1.3 --- 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 cd961276082..a0d8bc06640 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.1"] + "requirements": ["google-nest-sdm==7.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 10abbbd380d..789936e56b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1036,7 +1036,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.1 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0ab40dc5a5..de727970657 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,7 +886,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.1 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 2af3a56ab32546033726057bbd95ec8dc01e76be Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 7 Feb 2025 21:36:30 +1300 Subject: [PATCH 278/288] Bump Electrickiwi-api to 0.9.14 (#137614) * bump library to fix bug with post * rebuild --- homeassistant/components/electric_kiwi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 1d4e26d5e0d..45bb09ca475 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.9.13"] + "requirements": ["electrickiwi-api==0.9.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 789936e56b6..4c3c2b42424 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.13 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de727970657..ef9b2978459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -702,7 +702,7 @@ easyenergy==2.1.2 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.13 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 From eaf31051c57984a6da3b6d47535669c780034a3d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 7 Feb 2025 01:43:32 -0700 Subject: [PATCH 279/288] Add HNT wallet/rate to coinbase (#137592) --- homeassistant/components/coinbase/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 0f47d4bc208..f20b23dad7a 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -132,6 +132,7 @@ WALLETS = { "GYD": "GYD", "HKD": "HKD", "HNL": "HNL", + "HNT": "HNT", "HRK": "HRK", "HTG": "HTG", "HUF": "HUF", @@ -410,6 +411,7 @@ RATES = { "GYEN": "GYEN", "HKD": "HKD", "HNL": "HNL", + "HNT": "HNT", "HRK": "HRK", "HTG": "HTG", "HUF": "HUF", From b9a9da1e1d003f063cd116969bf60bfd60765d45 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:44:14 +0100 Subject: [PATCH 280/288] Add event platform to Bring! integration (#136935) * Add event platform * update --- homeassistant/components/bring/__init__.py | 2 +- homeassistant/components/bring/coordinator.py | 8 +- homeassistant/components/bring/diagnostics.py | 6 +- homeassistant/components/bring/event.py | 108 ++++ homeassistant/components/bring/icons.json | 5 + homeassistant/components/bring/strings.json | 14 + tests/components/bring/conftest.py | 9 + tests/components/bring/fixtures/activity.json | 62 +++ tests/components/bring/fixtures/users.json | 31 ++ .../bring/snapshots/test_diagnostics.ambr | 472 ++++++++++++++---- .../bring/snapshots/test_event.ambr | 167 +++++++ tests/components/bring/test_event.py | 46 ++ tests/components/bring/test_util.py | 13 +- 13 files changed, 848 insertions(+), 95 deletions(-) create mode 100644 homeassistant/components/bring/event.py create mode 100644 tests/components/bring/fixtures/activity.json create mode 100644 tests/components/bring/fixtures/users.json create mode 100644 tests/components/bring/snapshots/test_event.ambr create mode 100644 tests/components/bring/test_event.py diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index a4695e4f958..6dd2d36351c 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import BringConfigEntry, BringDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index db5ed9736a8..e1f9fa45ac8 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -8,12 +8,14 @@ import logging from bring_api import ( Bring, + BringActivityResponse, BringAuthException, BringItemsResponse, BringList, BringParseException, BringRequestException, BringUserSettingsResponse, + BringUsersResponse, ) from mashumaro.mixins.orjson import DataClassORJSONMixin @@ -37,6 +39,8 @@ class BringData(DataClassORJSONMixin): lst: BringList content: BringItemsResponse + activity: BringActivityResponse + users: BringUsersResponse class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): @@ -94,6 +98,8 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): continue try: items = await self.bring.get_list(lst.listUuid) + activity = await self.bring.get_activity(lst.listUuid) + users = await self.bring.get_list_users(lst.listUuid) except BringRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -105,7 +111,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_parse_exception", ) from e else: - list_dict[lst.listUuid] = BringData(lst, items) + list_dict[lst.listUuid] = BringData(lst, items, activity, users) return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index f4540547c4d..6c2f779ef05 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -14,4 +14,8 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()} + return { + "data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()}, + "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], + "user_settings": config_entry.runtime_data.user_settings.to_dict(), + } diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py new file mode 100644 index 00000000000..2dc173b6e42 --- /dev/null +++ b/homeassistant/components/bring/event.py @@ -0,0 +1,108 @@ +"""Event platform for Bring integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime + +from bring_api import ActivityType, BringList + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BringConfigEntry +from .coordinator import BringDataUpdateCoordinator +from .entity import BringBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BringConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the event platform.""" + coordinator = config_entry.runtime_data + lists_added: set[str] = set() + + @callback + def add_entities() -> None: + """Add event entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringEventEntity( + coordinator, + bring_list, + ) + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() + + +class BringEventEntity(BringBaseEntity, EventEntity): + """An event entity.""" + + _attr_translation_key = "activities" + + def __init__( + self, + coordinator: BringDataUpdateCoordinator, + bring_list: BringList, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, bring_list) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{self._list_uuid}_activities" + ) + self._attr_event_types = [event.name.lower() for event in ActivityType] + + def _async_handle_event(self) -> None: + """Handle the activity event.""" + bring_list = self.coordinator.data[self._list_uuid] + last_event_triggered = self.state + if bring_list.activity.timeline and ( + last_event_triggered is None + or datetime.fromisoformat(last_event_triggered) + < bring_list.activity.timestamp + ): + activity = bring_list.activity.timeline[0] + attributes = asdict(activity.content) + + attributes["last_activity_by"] = next( + x.name + for x in bring_list.users.users + if x.publicUuid == activity.content.publicUserUuid + ) + + self._trigger_event( + activity.type.name.lower(), + attributes, + ) + self.async_write_ha_state() + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + + return ( + f"https://api.getbring.com/rest/v2/bringusers/profilepictures/{public_uuid}" + if (public_uuid := self.state_attributes.get("publicUserUuid")) + else super().entity_picture + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks with your device API/library.""" + await super().async_added_to_hass() + self._async_handle_event() + + def _handle_coordinator_update(self): + self._async_handle_event() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index c670ef87700..ea4f4e877bc 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -1,5 +1,10 @@ { "entity": { + "event": { + "activity": { + "default": "mdi:bell" + } + }, "sensor": { "urgent": { "default": "mdi:run-fast" diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 70250849c79..7adf1a6a993 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -41,6 +41,20 @@ } }, "entity": { + "event": { + "activities": { + "name": "Activities", + "state_attributes": { + "event_type": { + "state": { + "list_items_added": "Items added", + "list_items_changed": "Items changed", + "list_items_removed": "Items removed" + } + } + } + } + }, "sensor": { "urgent": { "name": "Urgent", diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 34a122fca47..da630f7fbc8 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -5,10 +5,12 @@ from unittest.mock import AsyncMock, patch import uuid from bring_api import ( + BringActivityResponse, BringAuthResponse, BringItemsResponse, BringListResponse, BringUserSettingsResponse, + BringUsersResponse, ) import pytest @@ -60,6 +62,13 @@ def mock_bring_client() -> Generator[AsyncMock]: client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json( load_fixture("usersettings.json", DOMAIN) ) + client.get_activity.return_value = BringActivityResponse.from_json( + load_fixture("activity.json", DOMAIN) + ) + client.get_list_users.return_value = BringUsersResponse.from_json( + load_fixture("users.json", DOMAIN) + ) + yield client diff --git a/tests/components/bring/fixtures/activity.json b/tests/components/bring/fixtures/activity.json new file mode 100644 index 00000000000..5e9a8c089d3 --- /dev/null +++ b/tests/components/bring/fixtures/activity.json @@ -0,0 +1,62 @@ +{ + "timeline": [ + { + "type": "LIST_ITEMS_CHANGED", + "content": { + "uuid": "673594a9-f92d-4cb6-adf1-d2f7a83207a4", + "purchase": [ + { + "uuid": "658a3770-1a03-4ee0-94a6-10362a642377", + "itemId": "Gurke", + "specification": "", + "attributes": [] + } + ], + "recently": [ + { + "uuid": "1ed22d3d-f19b-4530-a518-19872da3fd3e", + "itemId": "Milch", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T03:09:33.036Z", + "publicUserUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d" + } + }, + { + "type": "LIST_ITEMS_ADDED", + "content": { + "uuid": "9a16635c-dea2-4e00-904a-c5034f9cfecf", + "items": [ + { + "uuid": "66a633a2-ae09-47bf-8845-3c0198480544", + "itemId": "Joghurt", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T02:54:57.656Z", + "publicUserUuid": "73af455f-c158-4004-a5e0-79f4f8a6d4bd" + } + }, + { + "type": "LIST_ITEMS_REMOVED", + "content": { + "uuid": "303dedf6-d4b2-4d25-a8cd-1c7967b84fcb", + "items": [ + { + "uuid": "2ba8ddb6-01c6-4b0b-a89d-f3da6b291528", + "itemId": "Tofu", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T03:09:12.380Z", + "publicUserUuid": "7d5e9d08-877a-4c36-8740-a9bf74ec690a" + } + } + ], + "timestamp": "2025-01-01T03:09:33.036Z", + "totalEvents": 3 +} diff --git a/tests/components/bring/fixtures/users.json b/tests/components/bring/fixtures/users.json new file mode 100644 index 00000000000..c9393dcb20d --- /dev/null +++ b/tests/components/bring/fixtures/users.json @@ -0,0 +1,31 @@ +{ + "users": [ + { + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "name": "Bring", + "email": "test-email", + "photoPath": "", + "pushEnabled": true, + "plusTryOut": false, + "country": "DE", + "language": "de" + }, + { + "publicUuid": "73af455f-c158-4004-a5e0-79f4f8a6d4bd", + "name": "NAME", + "email": "EMAIL", + "photoPath": "", + "pushEnabled": true, + "plusTryOut": false, + "country": "US", + "language": "en" + }, + { + "publicUuid": "7d5e9d08-877a-4c36-8740-a9bf74ec690a", + "pushEnabled": true, + "plusTryOut": false, + "country": "US", + "language": "en" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 740f4902fc3..951c3d3f808 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -1,113 +1,407 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ - 'content': dict({ - 'items': dict({ - 'purchase': list([ + 'data': dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'activity': dict({ + 'timeline': list([ dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, + 'content': dict({ + 'items': list([ + ]), + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': '2025-01-01T03:09:33.036000+00:00', + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'type': 'LIST_ITEMS_CHANGED', }), dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Joghurt', + 'specification': '', + 'uuid': '66a633a2-ae09-47bf-8845-3c0198480544', }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + ]), + 'publicUserUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T02:54:57.656000+00:00', + 'uuid': '9a16635c-dea2-4e00-904a-c5034f9cfecf', + }), + 'type': 'LIST_ITEMS_ADDED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Tofu', + 'specification': '', + 'uuid': '2ba8ddb6-01c6-4b0b-a89d-f3da6b291528', + }), + ]), + 'publicUserUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T03:09:12.380000+00:00', + 'uuid': '303dedf6-d4b2-4d25-a8cd-1c7967b84fcb', + }), + 'type': 'LIST_ITEMS_REMOVED', }), ]), - 'recently': list([ + 'timestamp': '2025-01-01T03:09:33.036000+00:00', + 'totalEvents': 3, + }), + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + 'users': dict({ + 'users': list([ dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + 'country': 'DE', + 'email': 'test-email', + 'language': 'de', + 'name': 'Bring', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': 'EMAIL', + 'language': 'en', + 'name': 'NAME', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, }), ]), }), - 'status': 'REGISTERED', - 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', }), - 'lst': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'theme': 'ch.publisheria.bring.theme.home', + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ + 'activity': dict({ + 'timeline': list([ + dict({ + 'content': dict({ + 'items': list([ + ]), + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': '2025-01-01T03:09:33.036000+00:00', + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'type': 'LIST_ITEMS_CHANGED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Joghurt', + 'specification': '', + 'uuid': '66a633a2-ae09-47bf-8845-3c0198480544', + }), + ]), + 'publicUserUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T02:54:57.656000+00:00', + 'uuid': '9a16635c-dea2-4e00-904a-c5034f9cfecf', + }), + 'type': 'LIST_ITEMS_ADDED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Tofu', + 'specification': '', + 'uuid': '2ba8ddb6-01c6-4b0b-a89d-f3da6b291528', + }), + ]), + 'publicUserUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T03:09:12.380000+00:00', + 'uuid': '303dedf6-d4b2-4d25-a8cd-1c7967b84fcb', + }), + 'type': 'LIST_ITEMS_REMOVED', + }), + ]), + 'timestamp': '2025-01-01T03:09:33.036000+00:00', + 'totalEvents': 3, + }), + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + }), + 'lst': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': 'test-email', + 'language': 'de', + 'name': 'Bring', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': 'EMAIL', + 'language': 'en', + 'name': 'NAME', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), + ]), + }), }), }), - 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ - 'content': dict({ - 'items': dict({ - 'purchase': list([ - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', - }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - }), - 'status': 'REGISTERED', - 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - }), - 'lst': dict({ + 'lists': list([ + dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', 'name': 'Einkauf', 'theme': 'ch.publisheria.bring.theme.home', }), + dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + ]), + 'user_settings': dict({ + 'userlistsettings': list([ + dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'usersettings': list([ + dict({ + 'key': 'listSectionOrder', + 'value': '["Früchte & Gemüse","Brot & Gebäck","Milch & Käse","Fleisch & Fisch","Zutaten & Gewürze","Fertig- & Tiefkühlprodukte","Getreideprodukte","Snacks & Süsswaren","Getränke & Tabak","Haushalt & Gesundheit","Pflege & Gesundheit","Tierbedarf","Baumarkt & Garten","Eigene Artikel"]', + }), + dict({ + 'key': 'listArticleLanguage', + 'value': 'de-DE', + }), + ]), + }), + dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'usersettings': list([ + dict({ + 'key': 'listSectionOrder', + 'value': '["Früchte & Gemüse","Brot & Gebäck","Milch & Käse","Fleisch & Fisch","Zutaten & Gewürze","Fertig- & Tiefkühlprodukte","Getreideprodukte","Snacks & Süsswaren","Getränke & Tabak","Haushalt & Gesundheit","Pflege & Gesundheit","Tierbedarf","Baumarkt & Garten","Eigene Artikel"]', + }), + dict({ + 'key': 'listArticleLanguage', + 'value': 'en-US', + }), + ]), + }), + ]), + 'usersettings': list([ + dict({ + 'key': 'autoPush', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideOffersBadge', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideSponsoredCategories', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideInspirationsBadge', + 'value': 'ON', + }), + dict({ + 'key': 'onboardClient', + 'value': 'android', + }), + dict({ + 'key': 'premiumHideOffersOnMain', + 'value': 'ON', + }), + dict({ + 'key': 'defaultListUUID', + 'value': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + }), + ]), }), }) # --- diff --git a/tests/components/bring/snapshots/test_event.ambr b/tests/components/bring/snapshots/test_event.ambr new file mode 100644 index 00000000000..907467bd6bb --- /dev/null +++ b/tests/components/bring/snapshots/test_event.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_setup[event.baumarkt_activities-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.baumarkt_activities', + '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': 'Activities', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activities', + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_activities', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.baumarkt_activities-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://api.getbring.com/rest/v2/bringusers/profilepictures/9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'event_type': 'list_items_changed', + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + 'friendly_name': 'Baumarkt Activities', + 'items': list([ + ]), + 'last_activity_by': 'Bring', + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': HAFakeDatetime(2025, 1, 1, 3, 9, 33, 36000, tzinfo=datetime.timezone.utc), + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'context': , + 'entity_id': 'event.baumarkt_activities', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T03:30:00.000+00:00', + }) +# --- +# name: test_setup[event.einkauf_activities-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.einkauf_activities', + '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': 'Activities', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activities', + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_activities', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.einkauf_activities-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://api.getbring.com/rest/v2/bringusers/profilepictures/9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'event_type': 'list_items_changed', + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + 'friendly_name': 'Einkauf Activities', + 'items': list([ + ]), + 'last_activity_by': 'Bring', + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': HAFakeDatetime(2025, 1, 1, 3, 9, 33, 36000, tzinfo=datetime.timezone.utc), + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'context': , + 'entity_id': 'event.einkauf_activities', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T03:30:00.000+00:00', + }) +# --- diff --git a/tests/components/bring/test_event.py b/tests/components/bring/test_event.py new file mode 100644 index 00000000000..99b96c27153 --- /dev/null +++ b/tests/components/bring/test_event.py @@ -0,0 +1,46 @@ +"""Test for event platform of the Bring! integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.bring.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_bring_client") +@freeze_time("2025-01-01T03:30:00.000Z") +async def test_setup( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of event platform.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, bring_config_entry.entry_id + ) diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 3060f31c134..673c4e68a4d 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,6 +1,12 @@ """Test for utility functions of the Bring! integration.""" -from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse +from bring_api import ( + BringActivityResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, +) +from bring_api.types import BringUsersResponse import pytest from homeassistant.components.bring.const import DOMAIN @@ -41,9 +47,10 @@ def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) - + activity = BringActivityResponse.from_json(load_fixture("activity.json", DOMAIN)) + users = BringUsersResponse.from_json(load_fixture("users.json", DOMAIN)) result = sum_attributes( - BringData(lst.lists[0], items), + BringData(lst.lists[0], items, activity, users), attribute, ) From 734f531a565f2bee57d1e69776e3f3710257691e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:46:53 +0100 Subject: [PATCH 281/288] Pass in the config_entry in youless coordinator init (#137471) explicitly pass in the config_entry in coordinator init --- homeassistant/components/youless/__init__.py | 2 +- homeassistant/components/youless/coordinator.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 03a27b5a378..af14d597b79 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except URLError as exception: raise ConfigEntryNotReady from exception - youless_coordinator = YouLessCoordinator(hass, api) + youless_coordinator = YouLessCoordinator(hass, entry, api) await youless_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py index 0be5e463689..81e4b3a4c76 100644 --- a/homeassistant/components/youless/coordinator.py +++ b/homeassistant/components/youless/coordinator.py @@ -5,6 +5,7 @@ import logging from youless_api import YoulessAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,10 +15,18 @@ _LOGGER = logging.getLogger(__name__) class YouLessCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching YouLess data.""" - def __init__(self, hass: HomeAssistant, device: YoulessAPI) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: YoulessAPI + ) -> None: """Initialize global YouLess data provider.""" super().__init__( - hass, _LOGGER, name="youless_gateway", update_interval=timedelta(seconds=10) + hass, + _LOGGER, + config_entry=config_entry, + name="youless_gateway", + update_interval=timedelta(seconds=10), ) self.device = device From b6c1c10035d23556206fcf9b0eed1b597750f606 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:52:52 +0100 Subject: [PATCH 282/288] Use runtime_data in freedompro (#137635) --- .../components/freedompro/__init__.py | 30 +++++++------------ .../components/freedompro/binary_sensor.py | 9 +++--- .../components/freedompro/climate.py | 9 +++--- .../components/freedompro/coordinator.py | 18 +++++++++-- homeassistant/components/freedompro/cover.py | 9 +++--- homeassistant/components/freedompro/fan.py | 9 +++--- homeassistant/components/freedompro/light.py | 9 +++--- homeassistant/components/freedompro/lock.py | 9 +++--- homeassistant/components/freedompro/sensor.py | 9 +++--- homeassistant/components/freedompro/switch.py | 9 +++--- 10 files changed, 65 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index c14c2f5ae36..9ce7701216c 100644 --- a/homeassistant/components/freedompro/__init__.py +++ b/homeassistant/components/freedompro/__init__.py @@ -2,17 +2,12 @@ from __future__ import annotations -import logging from typing import Final -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator PLATFORMS: Final[list[Platform]] = [ Platform.BINARY_SENSOR, @@ -26,32 +21,27 @@ PLATFORMS: Final[list[Platform]] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FreedomproConfigEntry) -> bool: """Set up Freedompro from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - api_key = entry.data[CONF_API_KEY] - - coordinator = FreedomproDataUpdateCoordinator(hass, api_key) + coordinator = FreedomproDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FreedomproConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: FreedomproConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py index ccea5faf41f..840150e807d 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -6,14 +6,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "smokeSensor": BinarySensorDeviceClass.SMOKE, @@ -33,10 +32,12 @@ SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactS async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro binary_sensor.""" - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index a5b0144ce0c..a0146dc70b3 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -15,7 +15,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -24,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -44,11 +43,13 @@ SUPPORTED_HVAC_MODES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro climate.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device( aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator diff --git a/homeassistant/components/freedompro/coordinator.py b/homeassistant/components/freedompro/coordinator.py index ad76a9aaa65..23b181b2655 100644 --- a/homeassistant/components/freedompro/coordinator.py +++ b/homeassistant/components/freedompro/coordinator.py @@ -8,6 +8,9 @@ from typing import Any from pyfreedompro import get_list, get_states +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,18 +18,27 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type FreedomproConfigEntry = ConfigEntry[FreedomproDataUpdateCoordinator] + class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Class to manage fetching Freedompro data API.""" - def __init__(self, hass, api_key): + def __init__(self, hass: HomeAssistant, entry: FreedomproConfigEntry) -> None: """Initialize.""" + self._hass = hass - self._api_key = api_key + self._api_key = entry.data[CONF_API_KEY] self._devices: list[dict[str, Any]] | None = None update_interval = timedelta(minutes=1) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self): if self._devices is None: diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index 06ad5c80b6a..ee61612428c 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -11,7 +11,6 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "windowCovering": CoverDeviceClass.BLIND, @@ -34,11 +33,13 @@ SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro cover.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index d21ede9bad3..ad520ac8eb8 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -8,7 +8,6 @@ from typing import Any from pyfreedompro import put_state from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -17,15 +16,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro fan.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( FreedomproFan(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index ab8df7ec9db..c1b2e0ea17b 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -13,7 +13,6 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -22,15 +21,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro light.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index c429ef6aa99..70423bb9514 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -6,7 +6,6 @@ from typing import Any from pyfreedompro import put_state from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -15,15 +14,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro lock.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 3c5101e3634..eaa96ac9fed 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -7,7 +7,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -15,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "temperatureSensor": SensorDeviceClass.TEMPERATURE, @@ -41,10 +40,12 @@ SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro sensor.""" - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 91e67506173..12346825474 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -6,7 +6,6 @@ from typing import Any from pyfreedompro import put_state from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -15,15 +14,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro switch.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data From 0e443bf74839dc8b35ac99c6fed77c98b4e579fe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:53:16 +0100 Subject: [PATCH 283/288] Fix fireservicerota unload (#137629) --- homeassistant/components/fireservicerota/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index 360a0f0b210..bf5385b6f2a 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -45,7 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload FireServiceRota config entry.""" await hass.async_add_executor_job( - hass.data[DOMAIN][entry.entry_id].websocket.stop_listener + hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener ) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: From 27cb88db1ad81993c952bbb59bacbb872ae5f751 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:53:39 +0100 Subject: [PATCH 284/288] Use runtime_data in fitbit (#137631) --- homeassistant/components/fitbit/__init__.py | 20 ++++++++------------ homeassistant/components/fitbit/sensor.py | 12 ++++++------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 22d0e302d63..0c4a37198d6 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -7,7 +7,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api -from .const import DOMAIN, FitbitScope +from .const import FitbitScope from .coordinator import FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import config_from_entry_data @@ -15,10 +15,11 @@ from .model import config_from_entry_data PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up fitbit from a config entry.""" - hass.data.setdefault(DOMAIN, {}) +type FitbitConfigEntry = ConfigEntry[FitbitData] + +async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: + """Set up fitbit from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -41,18 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = FitbitDeviceCoordinator(hass, fitbit_api) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = FitbitData( - api=fitbit_api, device_coordinator=coordinator - ) + entry.runtime_data = FitbitData(api=fitbit_api, device_coordinator=coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d58dad4ca67..4ccbea97a66 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -29,9 +28,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FitbitConfigEntry from .api import FitbitApi from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem -from .coordinator import FitbitData, FitbitDeviceCoordinator +from .coordinator import FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, config_from_entry_data @@ -131,7 +131,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): def _build_device_info( - config_entry: ConfigEntry, entity_description: FitbitSensorEntityDescription + config_entry: FitbitConfigEntry, entity_description: FitbitSensorEntityDescription ) -> DeviceInfo: """Build device info for sensor entities info across devices.""" unique_id = cast(str, config_entry.unique_id) @@ -524,12 +524,12 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FitbitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fitbit sensor platform.""" - data: FitbitData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data api = data.api # These are run serially to reuse the cached user profile, not gathered @@ -601,7 +601,7 @@ class FitbitSensor(SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: FitbitConfigEntry, api: FitbitApi, user_profile_id: str, description: FitbitSensorEntityDescription, From 5b8ef05bc212a12233dc8197a90f5d014a15f212 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:06:44 +0100 Subject: [PATCH 285/288] Use runtime_data in foscam (#137646) --- homeassistant/components/foscam/__init__.py | 27 +++++++------------ homeassistant/components/foscam/camera.py | 18 ++++--------- .../components/foscam/coordinator.py | 5 ++++ homeassistant/components/foscam/switch.py | 11 ++++---- tests/components/foscam/test_init.py | 14 ++++------ 5 files changed, 29 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 09df989447a..9643f333bb5 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -2,7 +2,6 @@ from libpyfoscam import FoscamCamera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -14,13 +13,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .config_flow import DEFAULT_RTSP_PORT -from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET -from .coordinator import FoscamCoordinator +from .const import CONF_RTSP_PORT, LOGGER +from .coordinator import FoscamConfigEntry, FoscamCoordinator PLATFORMS = [Platform.CAMERA, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: """Set up foscam from a config entry.""" session = FoscamCamera( @@ -30,11 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], verbose=False, ) - coordinator = FoscamCoordinator(hass, session) + coordinator = FoscamCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator # Migrate to correct unique IDs for switches await async_migrate_entities(hass, entry) @@ -44,20 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - if not hass.data[DOMAIN]: - hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ_PRESET) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", entry.version) @@ -97,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_migrate_entities(hass: HomeAssistant, entry: FoscamConfigEntry) -> None: """Migrate old entry.""" @callback diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 075848f6ffb..ed5ba1d4c21 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -7,21 +7,13 @@ import asyncio import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_RTSP_PORT, - CONF_STREAM, - DOMAIN, - LOGGER, - SERVICE_PTZ, - SERVICE_PTZ_PRESET, -) -from .coordinator import FoscamCoordinator +from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .coordinator import FoscamConfigEntry, FoscamCoordinator from .entity import FoscamEntity DIR_UP = "up" @@ -56,7 +48,7 @@ PTZ_GOTO_PRESET_COMMAND = "ptz_goto_preset" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a Foscam IP camera from a config entry.""" @@ -89,7 +81,7 @@ async def async_setup_entry( "async_perform_ptz_preset", ) - coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities([HassFoscamCamera(coordinator, config_entry)]) @@ -103,7 +95,7 @@ class HassFoscamCamera(FoscamEntity, Camera): def __init__( self, coordinator: FoscamCoordinator, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, ) -> None: """Initialize a Foscam camera.""" super().__init__(coordinator, config_entry.entry_id) diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index e7a8abf7d30..92eb7615e2a 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -6,11 +6,14 @@ from typing import Any from libpyfoscam import FoscamCamera +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER +type FoscamConfigEntry = ConfigEntry[FoscamCoordinator] + class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Foscam coordinator.""" @@ -18,12 +21,14 @@ class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): def __init__( self, hass: HomeAssistant, + entry: FoscamConfigEntry, session: FoscamCamera, ) -> None: """Initialize my coordinator.""" super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index dfc51aaa064..189271d2746 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -5,24 +5,23 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FoscamCoordinator -from .const import DOMAIN, LOGGER +from .const import LOGGER +from .coordinator import FoscamConfigEntry, FoscamCoordinator from .entity import FoscamEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up foscam switch from a config entry.""" - coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data await coordinator.async_config_entry_first_refresh() @@ -36,7 +35,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity): def __init__( self, coordinator: FoscamCoordinator, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, ) -> None: """Initialize a Foscam Sleep Switch.""" super().__init__(coordinator, config_entry.entry_id) diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py index 0b82ed3b02a..a7b6a8c8f0b 100644 --- a/tests/components/foscam/test_init.py +++ b/tests/components/foscam/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components.foscam import DOMAIN, config_flow +from homeassistant.components.foscam.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -18,9 +18,7 @@ async def test_unique_id_new_entry( entity_registry: er.EntityRegistry, ) -> None: """Test unique ID for a newly added device is correct.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID - ) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) entry.add_to_hass(hass) with ( @@ -46,7 +44,7 @@ async def test_switch_unique_id_migration_ok( ) -> None: """Test that the unique ID for a sleep switch is migrated to the new format.""" entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 + domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 ) entry.add_to_hass(hass) @@ -57,7 +55,7 @@ async def test_switch_unique_id_migration_ok( # Update config entry with version 2 entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 + domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 ) entry.add_to_hass(hass) @@ -84,9 +82,7 @@ async def test_unique_id_migration_not_needed( entity_registry: er.EntityRegistry, ) -> None: """Test that the unique ID for a sleep switch is not executed if already in right format.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID - ) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) entry.add_to_hass(hass) entity_registry.async_get_or_create( From 448a24802da4659f73dab33987d82891deac8005 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:08:02 +0100 Subject: [PATCH 286/288] Add reconfiguration flow to Bring! integration (#137529) --- homeassistant/components/bring/config_flow.py | 23 ++++ .../components/bring/quality_scale.yaml | 2 +- homeassistant/components/bring/strings.json | 18 +++- tests/components/bring/test_config_flow.py | 101 ++++++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 94f9e664a60..9e5f4da8356 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -107,6 +107,29 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + + if user_input: + if not (errors := await self.validate_input(user_input)): + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconf_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values={CONF_EMAIL: reconf_entry.data[CONF_EMAIL]}, + ), + errors=errors, + ) + async def validate_input(self, user_input: Mapping[str, Any]) -> dict[str, str]: """Auth Helper.""" diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 2714b92680b..58e67ab0e11 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 7adf1a6a993..f8c261db3fd 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -27,17 +27,31 @@ "email": "[%key:component::bring::config::step::user::data_description::email%]", "password": "[%key:component::bring::config::step::user::data_description::email%]" } + }, + "reconfigure": { + "title": "Bring! configuration", + "description": "Update your credentials if you have changed your Bring! account email or password.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::bring::config::step::user::data_description::email%]", + "password": "[%key:component::bring::config::step::user::data_description::email%]" + } } }, "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%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 306f63525d1..b9208324c61 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -210,3 +210,104 @@ async def test_flow_reauth_unique_id_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_flow_reconfigure( + hass: HomeAssistant, bring_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + bring_config_entry.add_to_hass(hass) + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert bring_config_entry.data[CONF_EMAIL] == "new-email" + assert bring_config_entry.data[CONF_PASSWORD] == "new-password" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (BringRequestException(), "cannot_connect"), + (BringAuthException(), "invalid_auth"), + (BringParseException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + bring_config_entry.add_to_hass(hass) + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_bring_client.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_bring_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert bring_config_entry.data[CONF_EMAIL] == "new-email" + assert bring_config_entry.data[CONF_PASSWORD] == "new-password" + + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test we abort reconfigure if unique id mismatch.""" + + mock_bring_client.uuid = "11111111-11111111-11111111-11111111" + + bring_config_entry.add_to_hass(hass) + + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" From 2639a3bce8f645042f67ed9da086d1316a7d0207 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:08:50 +0100 Subject: [PATCH 287/288] Fix missing type annotation in bring (#137647) --- homeassistant/components/bring/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 2dc173b6e42..699dba9015a 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -103,6 +103,6 @@ class BringEventEntity(BringBaseEntity, EventEntity): await super().async_added_to_hass() self._async_handle_event() - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: self._async_handle_event() return super()._handle_coordinator_update() From 8f83a4c485761b6175df41e07c27df3077ee1f67 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:09:56 +0100 Subject: [PATCH 288/288] Plugwise: fix double CONF_PASSWORD occurrence (#137641) Fix double CONF_PASSWORD occurrence --- homeassistant/components/plugwise/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 09c5392f12a..bf33d4c4a0f 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -59,7 +59,6 @@ def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: schema = schema.extend( { vol.Required(CONF_HOST): str, - vol.Required(CONF_PASSWORD): str, vol.Required(CONF_USERNAME, default=SMILE): vol.In( {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} ),