From 8749210d1b2b065e5da837971b26a08053f85e9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 001/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] 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/410] Bump version to 2025.2.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 542b034bd79..db5f31c2846 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 2eb93d28b27..8c60242b9fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b8" +version = "2025.2.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3dc075f2877340f89d3e7dbe2315630f27f9302a Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 5 Feb 2025 19:43:38 +1100 Subject: [PATCH 154/410] Bump pysmlight to v0.1.7 (#137390) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 63bae37f431..4bc2f36dddf 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.6"], + "requirements": ["pysmlight==0.1.7"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 79545b412aa..645bd3eb3af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.1.7 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe8afaec7c0..15b182d75a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.1.7 # homeassistant.components.snmp pysnmp==6.2.6 From d8179dacc66f2805be869413cb8bc7d9224e77b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Feb 2025 13:55:36 +0100 Subject: [PATCH 155/410] Report progress while restoring supervisor backup (#137313) --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 32 +++- tests/components/hassio/test_backup.py | 187 +++++++++++++++++++- 3 files changed, 214 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3ee6d2026f9..71a4f5ea41a 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -33,6 +33,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupStage, RestoreBackupState, WrittenBackup, ) @@ -61,6 +62,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", "async_get_manager", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index dfc161bd4e7..142c5fc01ce 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -39,6 +39,7 @@ from homeassistant.components.backup import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupStage, RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, @@ -548,6 +549,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" + if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown restore stage: %s", data.get("stage")) + else: + on_progress( + RestoreBackupEvent( + reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS + ) + ) if data.get("done") is True: restore_complete.set() restore_errors.extend(data.get("errors", [])) @@ -574,15 +583,26 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + sent_event = False + @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" + nonlocal sent_event + + if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))): + _LOGGER.debug("Unknown restore stage: %s", data.get("stage")) + if data.get("done") is not True: - on_progress( - RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + if stage or not sent_event: + sent_event = True + on_progress( + RestoreBackupEvent( + reason=None, + stage=stage, + state=RestoreBackupState.IN_PROGRESS, + ) ) - ) return restore_errors = data.get("errors", []) @@ -592,14 +612,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress( RestoreBackupEvent( reason="unknown_error", - stage=None, + stage=stage, state=RestoreBackupState.FAILED, ) ) else: on_progress( RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.COMPLETED + reason=None, stage=stage, state=RestoreBackupState.COMPLETED ) ) on_progress(IdleEvent()) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 459ebe581fb..cc62e77ea22 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2032,6 +2032,109 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + @pytest.mark.parametrize( ("supervisor_error", "expected_error_code", "expected_reason"), [ @@ -2261,7 +2364,7 @@ async def test_reader_writer_restore_wrong_parameters( TEST_JOB_DONE, { "manager_state": "restore_backup", - "reason": "", + "reason": None, "stage": None, "state": "completed", }, @@ -2302,6 +2405,88 @@ async def test_restore_progress_after_restart( assert response["result"]["state"] == "idle" +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + response = await client.receive_json() + assert response["success"] + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = ["addon_repositories", "home_assistant", "addons"] + expected_manager_states = ["in_progress", "in_progress", "completed"] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": expected_manager_states[i], + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": "addons", + "state": "completed", + } + assert response["result"]["state"] == "idle" + + @pytest.mark.usefixtures("hassio_client") async def test_restore_progress_after_restart_unknown_job( hass: HomeAssistant, From 16d9270833023eaabeffb8a924acb24b713973a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 02:29:23 -0600 Subject: [PATCH 156/410] Fix memory leak when unloading DataUpdateCoordinator (#137338) * check wiz * Fix memory leak when unloading DataUpdateCoordinator fixes #137237 * handle namespace conflict * handle namespace conflict * address review comments --- homeassistant/helpers/update_coordinator.py | 29 +++++++++++++-------- tests/components/homewizard/test_init.py | 5 ++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 943eadff19a..be765ff422d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,6 +6,7 @@ from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Generator from datetime import datetime, timedelta +from functools import partial import logging from random import randint from time import monotonic @@ -103,7 +104,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) - self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listeners: dict[int, tuple[CALLBACK_TYPE, object | None]] = {} + self._last_listener_id: int = 0 self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -148,21 +150,26 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._listeners.pop(remove_listener) - if not self._listeners: - self._unschedule_refresh() - - self._listeners[remove_listener] = (update_callback, context) + self._last_listener_id += 1 + self._listeners[self._last_listener_id] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - return remove_listener + return partial(self.__async_remove_listener_internal, self._last_listener_id) + + @callback + def __async_remove_listener_internal(self, listener_id: int) -> None: + """Remove a listener. + + This is an internal function that is not to be overridden + in subclasses as it may change in the future. + """ + self._listeners.pop(listener_id) + if not self._listeners: + self._unschedule_refresh() + self._debounced_refresh.async_cancel() @callback def async_update_listeners(self) -> None: diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 412ddb13eda..9139ef80d12 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock +import weakref from freezegun.api import FrozenDateTimeFactory from homewizard_energy.errors import DisabledError, UnauthorizedError @@ -25,6 +26,9 @@ async def test_load_unload_v1( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + weak_ref = weakref.ref(mock_config_entry.runtime_data) + assert weak_ref() is not None + assert mock_config_entry.state is ConfigEntryState.LOADED assert len(mock_homewizardenergy.combined.mock_calls) == 1 @@ -32,6 +36,7 @@ async def test_load_unload_v1( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert weak_ref() is None async def test_load_unload_v2( From 2c5fd4ee2a0bb6c7f7e011c12a82fc9b21d4ce1a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:59:59 +0100 Subject: [PATCH 157/410] Update led-ble to 1.1.5 (#137347) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 8608c0b2798..9a65f62202b 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 645bd3eb3af..5fc109bbae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.5 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15b182d75a7..ede0bf50df3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1098,7 +1098,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.4 +led-ble==1.1.5 # homeassistant.components.lektrico lektricowifi==0.0.43 From da8d300f291880a9d20a541578cf946bcc289a07 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 4 Feb 2025 22:31:05 +0100 Subject: [PATCH 158/410] Fix sqlalchemy deprecation warning that `declarative_base` has moved (#137360) --- tests/components/recorder/db_schema_9.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index 784e326e1c3..6cf7085e279 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -19,8 +19,7 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id From df2b29aef1ab1b64c59dd1df9f671da3ab61163c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 15:37:33 -0600 Subject: [PATCH 159/410] Bump led-ble to 1.1.6 (#137369) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 9a65f62202b..ff620da1993 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.5"] + "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5fc109bbae8..ea62f996179 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.5 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ede0bf50df3..4a19b59a352 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1098,7 +1098,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.5 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 From fa83591148a6ab8474a6676f6263ad3a769452de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Feb 2025 17:11:05 -0600 Subject: [PATCH 160/410] Allow ignored Bluetooth adapters to be set up from the user flow (#137373) --- .../components/bluetooth/config_flow.py | 6 +---- .../components/bluetooth/strings.json | 2 +- .../components/bluetooth/test_config_flow.py | 22 ++++++++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 5d03a9c9d0f..e76277306f5 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -140,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): title=adapter_title(adapter, details), data={} ) - configured_addresses = self._async_current_ids() + configured_addresses = self._async_current_ids(include_ignore=False) bluetooth_adapters = get_adapters() await bluetooth_adapters.refresh() self._adapters = bluetooth_adapters.adapters @@ -155,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) ] if not unconfigured_adapters: - ignored_adapters = len( - self._async_current_entries(include_ignore=True) - ) - len(self._async_current_entries(include_ignore=False)) return self.async_abort( reason="no_adapters", - description_placeholders={"ignored_adapters": str(ignored_adapters)}, ) if len(unconfigured_adapters) == 1: self._adapter = list(self._adapters)[0] diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 5f9a380d631..866b76c0985 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -23,7 +23,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." + "no_adapters": "No unconfigured Bluetooth adapters found." } }, "options": { diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 35c1ca1eafe..f0136396c22 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -517,8 +517,10 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non @pytest.mark.usefixtures("one_adapter") -async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: - """Test we give a hint that the adapter is ignored.""" +async def test_async_step_user_linux_adapter_replace_ignored( + hass: HomeAssistant, +) -> None: + """Test we can replace an ignored adapter from user flow.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="00:00:00:00:00:01", @@ -530,9 +532,19 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> context={"source": config_entries.SOURCE_USER}, data={}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_adapters" - assert result["description_placeholders"] == {"ignored_adapters": "1"} + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("enable_bluetooth") From 0764c7e7739cb7c5707a3432f9daba28c7875bfc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:09:33 +1000 Subject: [PATCH 161/410] Bump Tesla Fleet API to v0.9.8 (#137379) * v0.9.7 * v0.9.8 --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index fa0f336eb18..330745316d7 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6"] + "requirements": ["tesla-fleet-api==0.9.8"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 09cab85dfe4..136990e5347 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6", "teslemetry-stream==0.6.6"] + "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.6"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index f6015b0ef4e..ef4d366c779 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.6"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index ea62f996179..7fd95d08408 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2854,7 +2854,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a19b59a352..df91300646e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2294,7 +2294,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 79563f374669b51b3c75deb4032c7bdc335736b3 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 5 Feb 2025 18:43:22 +1000 Subject: [PATCH 162/410] Handle powerwall at zero percent in Tesla Fleet and Tessie (#137393) * Handle powerwall zero * Add missing value_fn call --- .../components/tesla_fleet/sensor.py | 29 ++++++++++--------- homeassistant/components/tessie/sensor.py | 2 ++ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 3e05e7e723b..c1d38bf85c5 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -303,8 +303,8 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslaFleetTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = ( + TeslaFleetSensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -312,7 +312,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -321,7 +321,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -331,14 +331,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -346,7 +347,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -354,7 +355,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -362,7 +363,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -370,7 +371,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -379,7 +380,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="island_status", options=[ "island_status_unknown", @@ -550,12 +551,12 @@ class TeslaFleetVehicleTimeSensorEntity(TeslaFleetVehicleEntity, SensorEntity): class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity): """Base class for Tesla Fleet energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslaFleetSensorEntityDescription def __init__( self, data: TeslaFleetEnergyData, - description: SensorEntityDescription, + description: TeslaFleetSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -563,7 +564,7 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 7f09cef2acd..323fa76ef1f 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -258,6 +258,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), ) + ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -292,6 +293,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), TessieSensorEntityDescription( key="battery_power", From c506c9080aafebf9d22a52c3d7e8583501ffe8a5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2025 05:42:41 -0500 Subject: [PATCH 163/410] Simplify llm calendar tool (#137402) * Simplify calendar tool * Clean up exposed entities --- .../components/mcp_server/llm_api.py | 4 +- homeassistant/helpers/llm.py | 69 +++++++++++-------- tests/helpers/test_llm.py | 15 +++- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py index f4292744815..5c29b29153e 100644 --- a/homeassistant/components/mcp_server/llm_api.py +++ b/homeassistant/components/mcp_server/llm_api.py @@ -35,13 +35,13 @@ class StatelessAssistAPI(llm.AssistAPI): """Return the prompt for the exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) entities = [ {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities.values() + for entity_info in exposed_entities["entities"].values() ] prompt.append(yaml_util.dump(list(entities))) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2bca4c8528b..b330494a1b8 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -326,7 +326,7 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - if not exposed_entities: + if not exposed_entities or not exposed_entities["entities"]: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." @@ -389,11 +389,11 @@ class AssistAPI(API): """Return the prompt for the API for exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml_util.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) return prompt @@ -425,8 +425,9 @@ class AssistAPI(API): exposed_domains: set[str] | None = None if exposed_entities is not None: exposed_domains = { - split_entity_id(entity_id)[0] for entity_id in exposed_entities + info["domain"] for info in exposed_entities["entities"].values() } + intent_handlers = [ intent_handler for intent_handler in intent_handlers @@ -438,25 +439,29 @@ class AssistAPI(API): IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) for intent_handler in intent_handlers ] - if exposed_domains and CALENDAR_DOMAIN in exposed_domains: - tools.append(CalendarGetEventsTool()) - if llm_context.assistant is not None: - for state in self.hass.states.async_all(SCRIPT_DOMAIN): - if not async_should_expose( - self.hass, llm_context.assistant, state.entity_id - ): - continue + if exposed_entities: + if exposed_entities[CALENDAR_DOMAIN]: + names = [] + for info in exposed_entities[CALENDAR_DOMAIN].values(): + names.extend(info["names"].split(", ")) + tools.append(CalendarGetEventsTool(names)) - tools.append(ScriptTool(self.hass, state.entity_id)) + tools.extend( + ScriptTool(self.hass, script_entity_id) + for script_entity_id in exposed_entities[SCRIPT_DOMAIN] + ) return tools def _get_exposed_entities( hass: HomeAssistant, assistant: str -) -> dict[str, dict[str, Any]]: - """Get exposed entities.""" +) -> dict[str, dict[str, dict[str, Any]]]: + """Get exposed entities. + + Splits out calendars and scripts. + """ area_registry = ar.async_get(hass) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -477,12 +482,13 @@ def _get_exposed_entities( } entities = {} + data: dict[str, dict[str, Any]] = { + SCRIPT_DOMAIN: {}, + CALENDAR_DOMAIN: {}, + } for state in hass.states.async_all(): - if ( - not async_should_expose(hass, assistant, state.entity_id) - or state.domain == SCRIPT_DOMAIN - ): + if not async_should_expose(hass, assistant, state.entity_id): continue description: str | None = None @@ -529,9 +535,13 @@ def _get_exposed_entities( }: info["attributes"] = attributes - entities[state.entity_id] = info + if state.domain in data: + data[state.domain][state.entity_id] = info + else: + entities[state.entity_id] = info - return entities + data["entities"] = entities + return data def _selector_serializer(schema: Any) -> Any: # noqa: C901 @@ -813,15 +823,18 @@ class CalendarGetEventsTool(Tool): name = "calendar_get_events" description = ( "Get events from a calendar. " - "When asked when something happens, search the whole week. " + "When asked if something happens, search the whole week. " "Results are RFC 5545 which means 'end' is exclusive." ) - parameters = vol.Schema( - { - vol.Required("calendar"): cv.string, - vol.Required("range"): vol.In(["today", "week"]), - } - ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index e288026b67b..630ed3f4fa1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1170,7 +1170,9 @@ async def test_selector_serializer( async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool.""" assert await async_setup_component(hass, "homeassistant", {}) - hass.states.async_set("calendar.test_calendar", "on", {"friendly_name": "Test"}) + hass.states.async_set( + "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} + ) async_expose_entity(hass, "conversation", "calendar.test_calendar", True) context = Context() llm_context = llm.LLMContext( @@ -1182,7 +1184,11 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool for tool in api.tools if tool.name == "calendar_get_events"] + tool = next( + (tool for tool in api.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] calls = async_mock_service( hass, @@ -1212,7 +1218,10 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: tool_input = llm.ToolInput( tool_name="calendar_get_events", - tool_args={"calendar": "calendar.test_calendar", "range": "today"}, + tool_args={ + "calendar": "Mock Calendar Name", + "range": "today", + }, ) now = dt_util.now() with patch("homeassistant.util.dt.now", return_value=now): From 30c099ef4e19fd89c6e145ac7533c4baafb5e2aa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 10:14:39 +0100 Subject: [PATCH 164/410] Allow creating backup if at least one agent is available (#137409) --- homeassistant/components/backup/manager.py | 61 +++++++++++++----- tests/components/backup/test_manager.py | 75 ++++++++++++++++++++-- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fa9ca956c22..24639ad4008 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io +from itertools import chain import json from pathlib import Path, PurePath import shutil @@ -827,7 +828,7 @@ class BackupManager: password=None, ) await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add(written_backup.backup, agent_errors, []) return written_backup.backup.backup_id async def async_create_backup( @@ -951,12 +952,23 @@ class BackupManager: with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" - if not agent_ids: - raise BackupManagerError("At least one agent must be selected") - if invalid_agents := [ + unavailable_agents = [ agent_id for agent_id in agent_ids if agent_id not in self.backup_agents - ]: - raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") + ] + if not ( + available_agents := [ + agent_id for agent_id in agent_ids if agent_id in self.backup_agents + ] + ): + raise BackupManagerError( + f"At least one available backup agent must be selected, got {agent_ids}" + ) + if unavailable_agents: + LOGGER.warning( + "Backup agents %s are not available, will backupp to %s", + unavailable_agents, + available_agents, + ) if include_all_addons and include_addons: raise BackupManagerError( "Cannot include all addons and specify specific addons" @@ -973,7 +985,7 @@ class BackupManager: new_backup, self._backup_task, ) = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, + agent_ids=available_agents, backup_name=backup_name, extra_metadata=extra_metadata | { @@ -992,7 +1004,9 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings, password), + self._async_finish_backup( + available_agents, unavailable_agents, with_automatic_settings, password + ), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -1009,7 +1023,11 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool, password: str | None + self, + available_agents: list[str], + unavailable_agents: list[str], + with_automatic_settings: bool, + password: str | None, ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -1028,7 +1046,7 @@ class BackupManager: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", written_backup.backup.backup_id, - agent_ids, + available_agents, ) self.async_on_backup_event( CreateBackupEvent( @@ -1041,13 +1059,15 @@ class BackupManager: try: agent_errors = await self._async_upload_backup( backup=written_backup.backup, - agent_ids=agent_ids, + agent_ids=available_agents, open_stream=written_backup.open_stream, password=password, ) finally: await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add( + written_backup.backup, agent_errors, unavailable_agents + ) if not agent_errors: if with_automatic_settings: # create backup was successful, update last_completed_automatic_backup @@ -1056,7 +1076,7 @@ class BackupManager: backup_success = True if with_automatic_settings: - self._update_issue_after_agent_upload(agent_errors) + self._update_issue_after_agent_upload(agent_errors, unavailable_agents) # delete old backups more numerous than copies # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) @@ -1216,10 +1236,10 @@ class BackupManager: ) def _update_issue_after_agent_upload( - self, agent_errors: dict[str, Exception] + self, agent_errors: dict[str, Exception], unavailable_agents: list[str] ) -> None: """Update issue registry after a backup is uploaded to agents.""" - if not agent_errors: + if not agent_errors and not unavailable_agents: ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") return ir.async_create_issue( @@ -1233,7 +1253,13 @@ class BackupManager: translation_key="automatic_backup_failed_upload_agents", translation_placeholders={ "failed_agents": ", ".join( - self.backup_agents[agent_id].name for agent_id in agent_errors + chain( + ( + self.backup_agents[agent_id].name + for agent_id in agent_errors + ), + unavailable_agents, + ) ) }, ) @@ -1302,11 +1328,12 @@ class KnownBackups: self, backup: AgentBackup, agent_errors: dict[str, Exception], + unavailable_agents: list[str], ) -> None: """Add a backup.""" self._backups[backup.backup_id] = KnownBackup( backup_id=backup.backup_id, - failed_agent_ids=list(agent_errors), + failed_agent_ids=list(chain(agent_errors, unavailable_agents)), ) self._manager.store.save() diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 57f11ed4708..aa7d7ebd95c 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -359,8 +359,14 @@ async def test_create_backup_when_busy( @pytest.mark.parametrize( ("parameters", "expected_error"), [ - ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"agent_ids": []}, + "At least one available backup agent must be selected, got []", + ), + ( + {"agent_ids": ["non_existing"]}, + "At least one available backup agent must be selected, got ['non_existing']", + ), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", @@ -410,6 +416,8 @@ async def test_create_backup_wrong_parameters( "name", "expected_name", "expected_filename", + "expected_agent_ids", + "expected_failed_agent_ids", "temp_file_unlink_call_count", ), [ @@ -419,6 +427,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -427,6 +437,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -435,6 +447,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], 0, ), ( @@ -443,6 +457,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -451,6 +467,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -459,6 +477,19 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + # Test we create a backup when at least one agent is available + [LOCAL_AGENT_ID, "test.unavailable"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + ["test.unavailable"], 0, ), ], @@ -486,6 +517,8 @@ async def test_initiate_backup( name: str | None, expected_name: str, expected_filename: str, + expected_agent_ids: list[str], + expected_failed_agent_ids: list[str], temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -620,13 +653,13 @@ async def test_initiate_backup( "addons": [], "agents": { agent_id: {"protected": bool(password), "size": ANY} - for agent_id in agent_ids + for agent_id in expected_agent_ids }, "backup_id": backup_id, "database_included": include_database, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, - "failed_agent_ids": [], + "failed_agent_ids": expected_failed_agent_ids, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -959,6 +992,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: @pytest.mark.parametrize( ( + "automatic_agents", "create_backup_command", "create_backup_side_effect", "agent_upload_side_effect", @@ -968,6 +1002,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: [ # No error ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, None, @@ -975,14 +1010,38 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, None, True, {}, ), + # One agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + None, + None, + True, + {}, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.unknown"}, + } + }, + ), # Error raised in async_initiate_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, Exception("Boom!"), None, @@ -990,6 +1049,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, Exception("Boom!"), None, @@ -1003,6 +1063,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised when awaiting the backup task ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, delayed_boom, None, @@ -1010,6 +1071,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, delayed_boom, None, @@ -1023,6 +1085,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised in async_upload_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, Exception("Boom!"), @@ -1030,6 +1093,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, Exception("Boom!"), @@ -1047,6 +1111,7 @@ async def test_create_backup_failure_raises_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, create_backup: AsyncMock, + automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, agent_upload_side_effect: Exception | None, @@ -1077,7 +1142,7 @@ async def test_create_backup_failure_raises_issue( await ws_client.send_json_auto_id( { "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.remote"]}, + "create_backup": {"agent_ids": automatic_agents}, } ) result = await ws_client.receive_json() From e8314fb286b9ff9fb6356f71bed52dc66ead07d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:14:06 +0100 Subject: [PATCH 165/410] Adjust logic for per-backup agent encryption (#137420) --- homeassistant/components/backup/manager.py | 6 ++- tests/components/backup/test_manager.py | 55 +++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 24639ad4008..25393a872cc 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1439,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] agent_config = manager.config.data.agents.get(self._local_agent_id) - if agent_config and not agent_config.protected: + if ( + self._local_agent_id in agent_ids + and agent_config + and not agent_config.protected + ): password = None backup = AgentBackup( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index aa7d7ebd95c..beaf942b21f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -46,6 +46,7 @@ from homeassistant.components.backup.manager import ( RestoreBackupState, WrittenBackup, ) +from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -3207,17 +3208,21 @@ async def test_restore_backup_file_error( @pytest.mark.parametrize( - ("commands", "password", "protected_backup"), + ("commands", "agent_ids", "password", "protected_backup", "inner_tar_key"), [ ( [], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, ), ( [], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3229,8 +3234,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": False}, + None, # None of the agents are protected ), ( [ @@ -3242,8 +3249,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": True}, + None, # Local agent is not protected ), ( [ @@ -3255,8 +3264,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": False}, + password_to_key("hunter2"), # Local agent is protected ), ( [ @@ -3268,8 +3279,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3281,8 +3294,40 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, # No password supplied + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": False}, + password_to_key("hunter2"), # Temporary backup protected when password set ), ], ) @@ -3291,13 +3336,15 @@ async def test_initiate_backup_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, + mocked_tarfile: Mock, path_glob: MagicMock, commands: dict[str, Any], + agent_ids: list[str], password: str | None, protected_backup: dict[str, bool], + inner_tar_key: bytes | None, ) -> None: """Test generate backup where encryption is selectively set on agents.""" - agent_ids = ["backup.local", "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -3373,6 +3420,10 @@ async def test_initiate_backup_per_agent_encryption( await hass.async_block_till_done() + mocked_tarfile.return_value.create_inner_tar.assert_called_once_with( + ANY, gzip=True, key=inner_tar_key + ) + result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, From 72a69d7e41ec54d18e90918b38802991e3bc93bb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 12:02:58 +0100 Subject: [PATCH 166/410] Adjust backup filename scheme (#137424) * Adjust backup filename scheme * Update tests --- homeassistant/components/backup/util.py | 2 +- tests/components/backup/test_backup.py | 4 +--- tests/components/backup/test_manager.py | 14 +++++++------- tests/components/backup/test_util.py | 8 ++++---- .../google_drive/snapshots/test_backup.ambr | 4 ++-- tests/components/hassio/test_backup.py | 4 ++-- tests/components/synology_dsm/test_backup.py | 6 +++--- 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index b920c66a9b8..9d8f6e815dc 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -122,7 +122,7 @@ def read_backup(backup_path: Path) -> AgentBackup: def suggested_filename_from_name_date(name: str, date_str: str) -> str: """Suggest a filename for the backup.""" date = dt_util.parse_datetime(date_str, raise_on_error=True) - return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + return "_".join(f"{name} {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) def suggested_filename(backup: AgentBackup) -> str: diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index c441cae292c..38b61ce65ea 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,9 +103,7 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert ( - move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" - ) + assert move_mock.mock_calls[0].args[1].name == "Test_1970-01-01_00.00_00000000.tar" @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index beaf942b21f..bdcb9f068b6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -427,7 +427,7 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], [], 0, @@ -447,7 +447,7 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID, "test.remote"], [], 0, @@ -457,7 +457,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], [], 0, @@ -477,7 +477,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID, "test.remote"], [], 0, @@ -488,7 +488,7 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", [LOCAL_AGENT_ID], ["test.unavailable"], 0, @@ -1677,7 +1677,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1686,7 +1686,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 3b188ff8226..504e0d56d58 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -529,10 +529,10 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("name", "resulting_filename"), [ - ("test", "test_-_2025-01-30_13.42_12345678.tar"), - (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), - ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), - ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ("test", "test_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_2025-01-30_13.42_12345678.tar"), ], ) def test_suggested_filename(name: str, resulting_filename: str) -> None: diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 9e1ec00b52e..2f3df3eed7f 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -140,7 +140,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'HA folder ID', ]), @@ -211,7 +211,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'new folder id', ]), diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index cc62e77ea22..89a789cdc50 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -887,7 +887,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), + filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -1400,7 +1400,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 26e09d407ff..ea68bbc991c 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -36,7 +36,7 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator -BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_2025-01-09_20.14_35457323" class MockStreamReaderChunked(MockStreamReader): @@ -525,7 +525,7 @@ async def test_agents_upload( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" with ( patch( @@ -576,7 +576,7 @@ async def test_agents_upload_error( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" # fail to upload the tar file with ( From a4474b2794322292a141618ebd87bf5a6cadc8f9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 12:26:27 +0000 Subject: [PATCH 167/410] Bump version to 2025.2.0b10 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index db5f31c2846..c35dbcaa378 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0b10" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 8c60242b9fd..740d7d7bc3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b9" +version = "2025.2.0b10" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5687a4d7185c20d23f3ed8767716318bdd37012d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Feb 2025 15:26:58 +0100 Subject: [PATCH 168/410] Bump onedrive to 0.0.8 (#137423) * Bump onedrive to 0.0.6 * bump to 0.0.7 * bump to 0.0.8 * Improve coverage --- homeassistant/components/onedrive/__init__.py | 14 +++++--- homeassistant/components/onedrive/api.py | 34 ------------------- homeassistant/components/onedrive/backup.py | 4 +-- .../components/onedrive/config_flow.py | 9 +++-- .../components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/onedrive/conftest.py | 30 +++++++++------- tests/components/onedrive/test_config_flow.py | 5 +++ tests/components/onedrive/test_init.py | 5 +++ 10 files changed, 46 insertions(+), 61 deletions(-) delete mode 100644 homeassistant/components/onedrive/api.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index ef7ddd04da6..5feefb2cf7d 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( @@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,7 +25,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .api import OneDriveConfigEntryAccessTokenProvider from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -31,7 +33,7 @@ class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" client: OneDriveClient - token_provider: OneDriveConfigEntryAccessTokenProvider + token_function: Callable[[], Awaitable[str]] backup_folder_id: str @@ -46,9 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - token_provider = OneDriveConfigEntryAccessTokenProvider(session) + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) - client = OneDriveClient(token_provider, async_get_clientsession(hass)) + client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: @@ -81,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.runtime_data = OneDriveRuntimeData( client=client, - token_provider=token_provider, + token_function=get_access_token, backup_folder_id=backup_folder.id, ) diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py deleted file mode 100644 index d8f6ea188f3..00000000000 --- a/homeassistant/components/onedrive/api.py +++ /dev/null @@ -1,34 +0,0 @@ -"""API for OneDrive bound to Home Assistant OAuth.""" - -from typing import cast - -from onedrive_personal_sdk import TokenProvider - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow - - -class OneDriveConfigFlowAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, token: str) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._token = token - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return self._token - - -class OneDriveConfigEntryAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._oauth_session = oauth_session - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 43eac020538..78bdcb24b8c 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -109,7 +109,7 @@ class OneDriveBackupAgent(BackupAgent): self._hass = hass self._entry = entry self._client = entry.runtime_data.client - self._token_provider = entry.runtime_data.token_provider + self._token_function = entry.runtime_data.token_function self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -145,7 +145,7 @@ class OneDriveBackupAgent(BackupAgent): ) try: item = await LargeFileUploadClient.upload( - self._token_provider, file, session=async_get_clientsession(self._hass) + self._token_function, file, session=async_get_clientsession(self._hass) ) except HashMismatchError as err: raise BackupAgentError( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index cbdf59648b9..900db0177d9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -12,7 +12,6 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -36,12 +35,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - token_provider = OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) + + async def get_access_token() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) graph_client = OneDriveClient( - token_provider, async_get_clientsession(self.hass) + get_access_token, async_get_clientsession(self.hass) ) try: diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 47eb48742be..88d51e6d73a 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.4"] + "requirements": ["onedrive-personal-sdk==0.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7fd95d08408..5dd70c1db86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df91300646e..8fb98c9ff57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index e76ce1d01c8..0d6ee09d587 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -67,8 +67,8 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture(autouse=True) -def mock_onedrive_client() -> Generator[MagicMock]: +@pytest.fixture +def mock_onedrive_client_init() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( @@ -80,19 +80,25 @@ def mock_onedrive_client() -> Generator[MagicMock]: new=onedrive_client, ), ): - client = onedrive_client.return_value - client.get_approot.return_value = MOCK_APPROOT - client.create_folder.return_value = MOCK_BACKUP_FOLDER - client.list_drive_items.return_value = [MOCK_BACKUP_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + yield onedrive_client - class MockStreamReader: - async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: - yield b"backup data" - client.download_drive_item.return_value = MockStreamReader() +@pytest.fixture(autouse=True) +def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + client = mock_onedrive_client_init.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - yield client + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_drive_item.return_value = MockStreamReader() + + return client @pytest.fixture diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 9acfd8ada3c..fb0d58b86c6 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -70,6 +70,7 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, + mock_onedrive_client_init: MagicMock, ) -> None: """Check full flow.""" @@ -79,6 +80,10 @@ async def test_full_flow( await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 674bc2d38d9..a6ad55442aa 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -16,10 +16,15 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_onedrive_client_init: MagicMock, ) -> None: """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) From c4e2ddd28b8f1949aaa77dd8569ab36fdbc6f0ab Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 5 Feb 2025 13:48:12 +0100 Subject: [PATCH 169/410] Bump reolink_aio to 0.11.9 (#137430) * Add push callbacks * Bump reolink_aio to 0.11.9 --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 5 +++++ homeassistant/components/reolink/select.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 83729fef3cd..fb3c096ee41 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.8"] + "requirements": ["reolink-aio==0.11.9"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e4b52c85d45..d8fabfaa3b8 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -424,6 +424,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_brightness", cmd_key="GetImage", + cmd_id=26, translation_key="image_brightness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -437,6 +438,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_contrast", cmd_key="GetImage", + cmd_id=26, translation_key="image_contrast", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -450,6 +452,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_saturation", cmd_key="GetImage", + cmd_id=26, translation_key="image_saturation", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -463,6 +466,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_sharpness", cmd_key="GetImage", + cmd_id=26, translation_key="image_sharpness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -476,6 +480,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_hue", cmd_key="GetImage", + cmd_id=26, translation_key="image_hue", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7a74be2e28c..df8c0269957 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -80,6 +80,7 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="day_night_mode", cmd_key="GetIsp", + cmd_id=26, translation_key="day_night_mode", entity_category=EntityCategory.CONFIG, get_options=[mode.name for mode in DayNightEnum], diff --git a/requirements_all.txt b/requirements_all.txt index 5dd70c1db86..036f76aa876 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,7 +2603,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fb98c9ff57..f7a34a729fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2106,7 +2106,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.9 # homeassistant.components.rflink rflink==0.0.66 From d6414b98499b034615558a5cc1142dcd020e30dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Feb 2025 16:58:39 +0100 Subject: [PATCH 170/410] Bump aiohasupervisor to version 0.3.0 (#137437) --- homeassistant/components/hassio/backup.py | 37 ++--- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_backup.py | 134 ++++++------------ 8 files changed, 71 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 142c5fc01ce..ddaa821587f 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -20,6 +20,7 @@ from aiohasupervisor.models import ( backups as supervisor_backups, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from homeassistant.components.backup import ( DATA_MANAGER, @@ -56,8 +57,6 @@ from homeassistant.util.enum import try_parse_enum from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client -LOCATION_CLOUD_BACKUP = ".cloud_backup" -LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" # Set on backups automatically created when updating an addon @@ -72,7 +71,9 @@ async def async_get_backup_agents( """Return the hassio backup agents.""" client = get_supervisor_client(hass) mounts = await client.mounts.info() - agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] + agents: list[BackupAgent] = [ + SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE) + ] for mount in mounts.mounts: if mount.usage is not supervisor_mounts.MountUsage.BACKUP: continue @@ -112,7 +113,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, location: str | None + details: supervisor_backups.BackupComplete, location: str ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -125,7 +126,6 @@ def _backup_details_to_agent_backup( for addon in details.addons ] extra_metadata = details.extra or {} - location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -148,7 +148,7 @@ class SupervisorBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: + def __init__(self, hass: HomeAssistant, name: str, location: str) -> None: """Initialize the backup agent.""" super().__init__() self._hass = hass @@ -206,7 +206,7 @@ class SupervisorBackupAgent(BackupAgent): backup_list = await self._client.backups.list() result = [] for backup in backup_list: - if not backup.locations or self.location not in backup.locations: + if self.location not in backup.location_attributes: continue details = await self._client.backups.backup_info(backup.slug) result.append(_backup_details_to_agent_backup(details, self.location)) @@ -222,7 +222,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) except SupervisorNotFoundError: return None - if self.location not in details.locations: + if self.location not in details.location_attributes: return None return _backup_details_to_agent_backup(details, self.location) @@ -295,8 +295,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): # will be handled by async_upload_backup. # If the lists are the same length, it does not matter which one we send, # we send the encrypted list to have a well defined behavior. - encrypted_locations: list[str | None] = [] - decrypted_locations: list[str | None] = [] + encrypted_locations: list[str] = [] + decrypted_locations: list[str] = [] agents_settings = manager.config.data.agents for hassio_agent in hassio_agents: if password is not None: @@ -353,12 +353,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): eager_start=False, # To ensure the task is not started before we return ) - return (NewBackup(backup_job_id=backup.job_id), backup_task) + return (NewBackup(backup_job_id=backup.job_id.hex), backup_task) async def _async_wait_for_backup( self, backup: supervisor_backups.NewBackup, - locations: list[str | None], + locations: list[str], *, on_progress: Callable[[CreateBackupEvent], None], remove_after_upload: bool, @@ -508,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): else None ) - restore_location: str | None + restore_location: str if manager.backup_agents[agent_id].domain != DOMAIN: # Download the backup to the supervisor. Supervisor will clean up the backup # two days after the restore is done. @@ -577,10 +577,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" - if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)): _LOGGER.debug("No restore job ID found in environment") return + restore_job_id = UUID(restore_job_str) _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) sent_event = False @@ -634,7 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def _async_listen_job_events( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> Callable[[], None]: """Listen for job events.""" @@ -649,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): if ( data.get("event") != "job" or not (event_data := data.get("data")) - or event_data.get("uuid") != job_id + or event_data.get("uuid") != job_id.hex ): return on_event(event_data) @@ -660,10 +661,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return unsub async def _get_job_state( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> None: """Poll a job for its state.""" - job = await self._client.jobs.get_job(UUID(job_id)) + job = await self._client.jobs.get_job(job_id) _LOGGER.debug("Job state: %s", job) on_event(job.to_dict()) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ccc0f23fb43..ad98beb5baa 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b6"], + "requirements": ["aiohasupervisor==0.3.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5e22abb831b..a08b71e80bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.3 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 diff --git a/pyproject.toml b/pyproject.toml index 740d7d7bc3e..7790624269a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b6", + "aiohasupervisor==0.3.0", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", diff --git a/requirements.txt b/requirements.txt index a58065a3a7a..0f5ac0ba7d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 036f76aa876..c79123cd3da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7a34a729fa..7690b454fa6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 89a789cdc50..cf03ac35f52 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -26,6 +26,7 @@ from aiohasupervisor.models import ( jobs as supervisor_jobs, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,11 +40,7 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import ( - LOCATION_CLOUD_BACKUP, - LOCATION_LOCAL, - RESTORE_JOB_ID_ENV, -) +from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -60,17 +57,12 @@ TEST_BACKUP = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -89,14 +81,9 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP.location, location_attributes=TEST_BACKUP.location_attributes, - locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, slug=TEST_BACKUP.slug, supervisor_version="2024.11.2", type=TEST_BACKUP.type, @@ -110,17 +97,12 @@ TEST_BACKUP_2 = supervisor_backups.Backup( homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -139,14 +121,9 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_2.location, location_attributes=TEST_BACKUP_2.location_attributes, - locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, - protected=TEST_BACKUP_2.protected, repositories=[], - size=TEST_BACKUP_2.size, - size_bytes=TEST_BACKUP_2.size_bytes, slug=TEST_BACKUP_2.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_2.type, @@ -160,17 +137,12 @@ TEST_BACKUP_3 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location="share", location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={"share"}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -189,14 +161,9 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_3.location, location_attributes=TEST_BACKUP_3.location_attributes, - locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, - protected=TEST_BACKUP_3.protected, repositories=[], - size=TEST_BACKUP_3.size, - size_bytes=TEST_BACKUP_3.size_bytes, slug=TEST_BACKUP_3.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_3.type, @@ -211,17 +178,12 @@ TEST_BACKUP_4 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -240,14 +202,9 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP_4.location, location_attributes=TEST_BACKUP_4.location_attributes, - locations=TEST_BACKUP_4.locations, name=TEST_BACKUP_4.name, - protected=TEST_BACKUP_4.protected, repositories=[], - size=TEST_BACKUP_4.size, - size_bytes=TEST_BACKUP_4.size_bytes, slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_4.type, @@ -261,17 +218,12 @@ TEST_BACKUP_5 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=LOCATION_CLOUD_BACKUP, location_attributes={ LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={LOCATION_CLOUD_BACKUP}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -290,14 +242,9 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP_5.location, location_attributes=TEST_BACKUP_5.location_attributes, - locations=TEST_BACKUP_5.locations, name=TEST_BACKUP_5.name, - protected=TEST_BACKUP_5.protected, repositories=[], - size=TEST_BACKUP_5.size, - size_bytes=TEST_BACKUP_5.size_bytes, slug=TEST_BACKUP_5.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_5.type, @@ -312,6 +259,7 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=False, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_JOB_DONE = supervisor_jobs.Job( @@ -322,6 +270,7 @@ TEST_JOB_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=True, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( @@ -340,6 +289,7 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( ), ) ], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) @@ -580,7 +530,10 @@ async def test_agent_download( assert await resp.content.read() == b"backup data" supervisor_client.backups.download_backup.assert_called_once_with( - "abc123", options=supervisor_backups.DownloadBackupOptions(location=None) + "abc123", + options=supervisor_backups.DownloadBackupOptions( + location=LOCATION_LOCAL_STORAGE + ), ) @@ -766,7 +719,10 @@ async def test_agent_delete_backup( assert response["success"] assert response["result"] == {"agent_errors": {}} supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -812,7 +768,10 @@ async def test_agent_delete_with_error( assert response == {"id": 1, "type": "result"} | expected_response supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -891,7 +850,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, - location=[None], + location=[LOCATION_LOCAL_STORAGE], name="Test", password=None, ) @@ -947,7 +906,7 @@ async def test_reader_writer_create( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1022,7 +981,7 @@ async def test_reader_writer_create_report_progress( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1129,7 +1088,7 @@ async def test_reader_writer_create_job_done( """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1198,7 +1157,7 @@ async def test_reader_writer_create_job_done( None, ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], False, [], ), @@ -1207,7 +1166,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], "hunter2", - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], True, [], ), @@ -1225,7 +1184,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share1", "share2", "share3"], True, - [None], + [LOCATION_LOCAL_STORAGE], ), ( [ @@ -1242,7 +1201,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share2", "share3"], True, - [None, "share1"], + [LOCATION_LOCAL_STORAGE, "share1"], ), ( [ @@ -1258,7 +1217,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2"], + [LOCATION_LOCAL_STORAGE, "share1", "share2"], True, ["share3"], ), @@ -1274,7 +1233,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local"], None, - [None], + [LOCATION_LOCAL_STORAGE], False, [], ), @@ -1312,15 +1271,14 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, extra=DEFAULT_BACKUP_OPTIONS.extra, - locations=create_locations, location_attributes={ - location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + location: supervisor_backups.BackupLocationAttributes( protected=create_protected, - size_bytes=TEST_BACKUP_DETAILS.size_bytes, + size_bytes=1048576, ) for location in create_locations }, @@ -1514,7 +1472,7 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1581,7 +1539,7 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) @@ -1668,7 +1626,7 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1745,7 +1703,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1848,7 +1806,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1975,7 +1933,7 @@ async def test_reader_writer_restore( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = get_job_result @@ -2006,7 +1964,7 @@ async def test_reader_writer_restore( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2040,7 +1998,7 @@ async def test_reader_writer_restore_report_progress( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2071,7 +2029,7 @@ async def test_reader_writer_restore_report_progress( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2193,7 +2151,7 @@ async def test_reader_writer_restore_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2221,7 +2179,7 @@ async def test_reader_writer_restore_late_error( ) -> None: """Test restoring a backup with error.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2250,7 +2208,7 @@ async def test_reader_writer_restore_late_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) From 72a3c5296c2875babecebe3e8f26c10dc2361291 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Feb 2025 17:07:19 +0100 Subject: [PATCH 171/410] Update frontend to 20250205.0 (#137441) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b584fe5e2f0..d27785dcea5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250204.0"] + "requirements": ["home-assistant-frontend==20250205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a08b71e80bf..7a7fde68200 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c79123cd3da..16eb25c9e9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7690b454fa6..abbc78bdd77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 04b0d587c5b11fb47f9890af18347e2f0f6f954d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 16:18:01 +0000 Subject: [PATCH 172/410] Bump version to 2025.2.0b11 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c35dbcaa378..f3b186fd24f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b10" +PATCH_VERSION: Final = "0b11" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 7790624269a..8e4c355a479 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b10" +version = "2025.2.0b11" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d5ad91fce3a5c99728eee2bcf74df90b2aa0c502 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:03:54 +0100 Subject: [PATCH 173/410] Update bluetooth dependencies (#137353) --- homeassistant/components/bluetooth/manifest.json | 6 +++--- homeassistant/package_constraints.txt | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 22db886ef3f..32577b1bd7f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,11 +16,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.8.0", - "bluetooth-adapters==0.21.1", + "bleak-retry-connector==3.8.1", + "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.3", "dbus-fast==2.32.0", - "habluetooth==3.21.0" + "habluetooth==3.21.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a7fde68200..0bd196c2350 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,9 +19,9 @@ audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 bleak==0.22.3 -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.23.3 cached-ipaddress==0.8.0 @@ -33,7 +33,7 @@ dbus-fast==2.32.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.21.0 +habluetooth==3.21.1 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 16eb25c9e9f..c9c2b55d591 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ bizkaibus==0.1.1 bleak-esphome==2.7.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -622,7 +622,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.21.0 +habluetooth==3.21.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abbc78bdd77..d81fefb6f13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.7.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.0 +bleak-retry-connector==3.8.1 # homeassistant.components.bluetooth bleak==0.22.3 @@ -546,7 +546,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.1 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.4 # homeassistant.components.bluetooth -habluetooth==3.21.0 +habluetooth==3.21.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 95410586b1afa267a6f4a0e8e354702a1a685904 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:02:17 +0100 Subject: [PATCH 174/410] Update bluetooth-data-tools to 1.23.4 (#137374) Co-authored-by: J. Nick Koston --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 32577b1bd7f..a0405eb5ef5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.8.1", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", - "bluetooth-data-tools==1.23.3", + "bluetooth-data-tools==1.23.4", "dbus-fast==2.32.0", "habluetooth==3.21.1" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index a29a9834c9b..36d0150642e 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.3", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ff620da1993..309399e6958 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 90518c81483..445affbcd57 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.3"] + "requirements": ["bluetooth-data-tools==1.23.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0bd196c2350..7022cc20d29 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.8.1 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index c9c2b55d591..43afb90550a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -631,7 +631,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d81fefb6f13..845e76ce572 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -555,7 +555,7 @@ bluetooth-auto-recovery==1.4.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.3 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 From d5dd0f6ec1f7442184782c2fd6b2a7119b8656d5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Feb 2025 11:02:01 -0600 Subject: [PATCH 175/410] Bump hassil and intents (#137440) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0485cb75fcb..2d4a8053d75 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7022cc20d29..445e729dea6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,10 +35,10 @@ go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 hass-nabucasa==0.88.1 -hassil==2.2.0 +hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250205.0 -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 43afb90550a..2b4f2050856 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ hass-nabucasa==0.88.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1146,7 +1146,7 @@ holidays==0.65 home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 845e76ce572..fee045f942d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habluetooth==3.21.1 hass-nabucasa==0.88.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -975,7 +975,7 @@ holidays==0.65 home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 2c433ba362e..6c865612f1a 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 65fde6042f4c70bda96e9cbc75226512d794782f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 11:17:02 -0600 Subject: [PATCH 176/410] Bump dbus-fast to 2.33.0 (#137446) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.32.0...v2.33.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a0405eb5ef5..5d2b8ab6285 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.4", - "dbus-fast==2.32.0", + "dbus-fast==2.33.0", "habluetooth==3.21.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 445e729dea6..2c3513589b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.32.0 +dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2b4f2050856..b1028c3efad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fee045f942d..03779787e33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -631,7 +631,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 From 5c7cabed1eb1d5e227a3f1bcd76a15e5541b15ad Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 17:30:55 +0000 Subject: [PATCH 177/410] Bump version to 2025.2.0b12 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f3b186fd24f..07019df72ed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b11" +PATCH_VERSION: Final = "0b12" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 8e4c355a479..cab55773567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b11" +version = "2025.2.0b12" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3a88c9d6f45b8655c5422734acada258387fe094 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2025 17:35:07 +0000 Subject: [PATCH 178/410] 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 d1b22312ac3edce79f2e0d9afeba7f6b925357ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 21:04:52 -0600 Subject: [PATCH 179/410] 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 180/410] 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 181/410] 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 182/410] 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 183/410] 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 184/410] 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 185/410] 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 186/410] 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 187/410] 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 188/410] 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 189/410] 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 190/410] 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 191/410] 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 192/410] 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 193/410] 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 194/410] 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 195/410] 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 196/410] 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 197/410] 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 198/410] 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 199/410] 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 200/410] 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 201/410] 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 202/410] 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 203/410] 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 204/410] 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 205/410] 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 206/410] 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 207/410] 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 208/410] 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 209/410] 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 210/410] 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 211/410] 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 212/410] 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 213/410] 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 214/410] 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 215/410] 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 216/410] 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 217/410] 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 218/410] 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 219/410] 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 220/410] 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 221/410] 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 222/410] 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 223/410] 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 224/410] 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 225/410] 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 226/410] 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 227/410] 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 228/410] 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 229/410] 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 230/410] 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 231/410] 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 232/410] 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 233/410] 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 234/410] 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 235/410] 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 236/410] 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 237/410] 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 238/410] 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 239/410] 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} ), From 43239682226109baacc4149873b0d629146d05aa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Feb 2025 11:13:55 +0100 Subject: [PATCH 240/410] Replace "HassOS" with "Home Assistant OS" in homeassistant_hardware (#137637) Makes it consistent with all other occurrences. Also change "otbr" and "zha" to uppercase. --- homeassistant/components/homeassistant_hardware/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index b483df75d75..de328a54bb7 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -96,7 +96,7 @@ }, "notify_unknown_multipan_user": { "title": "Manual configuration may be needed", - "description": "Home Assistant can automatically change the channels for otbr and zha. If you have configured another integration to use the radio, for example Zigbee2MQTT, you will have to reconfigure the channel in that integration after completing this guide." + "description": "Home Assistant can automatically change the channels for OTBR and ZHA. If you have configured another integration to use the radio, for example Zigbee2MQTT, you will have to reconfigure the channel in that integration after completing this guide." }, "reconfigure_addon": { "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support" @@ -131,7 +131,7 @@ "addon_already_running": "Failed to start the {addon_name} add-on because it is already running.", "addon_set_config_failed": "Failed to set {addon_name} configuration.", "addon_start_failed": "Failed to start the {addon_name} add-on.", - "not_hassio": "The hardware options can only be configured on HassOS installations.", + "not_hassio": "The hardware options can only be configured on Home Assistant OS installations.", "zha_migration_failed": "The ZHA migration did not succeed." }, "progress": { From f06d209b2dc41880fc294f335059d95c3745441e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:14:45 +0100 Subject: [PATCH 241/410] Improve type hints in fireservicerota (#137628) --- .../components/fireservicerota/coordinator.py | 8 +++++--- homeassistant/components/fireservicerota/sensor.py | 9 +++++---- homeassistant/components/fireservicerota/switch.py | 14 ++++++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py index 35f839b3bdb..14a8c40e469 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -54,7 +54,9 @@ class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]): class FireServiceRotaOauth: """Handle authentication tokens.""" - def __init__(self, hass, entry, fsr): + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, fsr: FireServiceRota + ) -> None: """Initialize the oauth object.""" self._hass = hass self._entry = entry @@ -94,7 +96,7 @@ class FireServiceRotaOauth: class FireServiceRotaWebSocket: """Define a FireServiceRota websocket manager object.""" - def __init__(self, hass, entry): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the websocket object.""" self._hass = hass self._entry = entry @@ -128,7 +130,7 @@ class FireServiceRotaWebSocket: class FireServiceRotaClient: """Getting the latest data from fireservicerota.""" - def __init__(self, hass, entry): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the data object.""" self._hass = hass self._entry = entry diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 864838ddaff..b09d1295025 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import DATA_CLIENT, DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceRotaClient _LOGGER = logging.getLogger(__name__) @@ -32,13 +33,13 @@ class IncidentsSensor(RestoreEntity, SensorEntity): _attr_has_entity_name = True _attr_translation_key = "incidents" - def __init__(self, client): + def __init__(self, client: FireServiceRotaClient) -> None: """Initialize.""" self._client = client self._entry_id = self._client.entry_id self._attr_unique_id = f"{self._client.unique_id}_Incidents" - self._state = None - self._state_attributes = {} + self._state: str | None = None + self._state_attributes: dict[str, Any] = {} @property def icon(self) -> str: @@ -52,7 +53,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): return "mdi:fire-truck" @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index 22287653788..affd46c91bd 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -10,6 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,15 +33,20 @@ class ResponseSwitch(SwitchEntity): _attr_has_entity_name = True _attr_translation_key = "incident_response" - def __init__(self, coordinator, client, entry): + def __init__( + self, + coordinator: FireServiceUpdateCoordinator, + client: FireServiceRotaClient, + entry: ConfigEntry, + ) -> None: """Initialize.""" self._coordinator = coordinator self._client = client self._attr_unique_id = f"{entry.unique_id}_Response" self._entry_id = entry.entry_id - self._state = None - self._state_attributes = {} + self._state: bool | None = None + self._state_attributes: dict[str, Any] = {} self._state_icon = None @property @@ -54,7 +60,7 @@ class ResponseSwitch(SwitchEntity): return "mdi:forum" @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Get the assumed state of the switch.""" return self._state From 60fd07f501044ecfbe008546ffe471c501467833 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:26:25 +0100 Subject: [PATCH 242/410] Use runtime_data in frontier_silicon (#137633) --- .../components/frontier_silicon/__init__.py | 19 +++++++++++-------- .../frontier_silicon/media_player.py | 6 +++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 325af100005..71196c13f68 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -11,14 +11,18 @@ from homeassistant.const import CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_WEBFSAPI_URL, DOMAIN +from .const import CONF_WEBFSAPI_URL PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type FrontierSiliconConfigEntry = ConfigEntry[AFSAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: FrontierSiliconConfigEntry +) -> bool: """Set up Frontier Silicon from a config entry.""" webfsapi_url = entry.data[CONF_WEBFSAPI_URL] @@ -31,16 +35,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FSConnectionError as exception: raise ConfigEntryNotReady from exception - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi + entry.runtime_data = afsapi 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: FrontierSiliconConfigEntry +) -> 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/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 52998e03703..6b0f987baa2 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -20,11 +20,11 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import FrontierSiliconConfigEntry from .browse_media import browse_node, browse_top_level from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET @@ -33,12 +33,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FrontierSiliconConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Frontier Silicon entity.""" - afsapi: AFSAPI = hass.data[DOMAIN][config_entry.entry_id] + afsapi = config_entry.runtime_data async_add_entities( [ From ff42353e61aeec0072b937903fdf8eb83b07d77f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:27:41 +0100 Subject: [PATCH 243/410] Use runtime_data in fivem (#137632) --- homeassistant/components/fivem/__init__.py | 17 +++++--------- .../components/fivem/binary_sensor.py | 8 +++---- homeassistant/components/fivem/coordinator.py | 22 ++++++++++++------- homeassistant/components/fivem/sensor.py | 7 +++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/fivem/__init__.py b/homeassistant/components/fivem/__init__.py index 25d24502846..c69a8172272 100644 --- a/homeassistant/components/fivem/__init__.py +++ b/homeassistant/components/fivem/__init__.py @@ -6,20 +6,18 @@ import logging from fivem import FiveMServerOfflineError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import FiveMDataUpdateCoordinator +from .coordinator import FiveMConfigEntry, FiveMDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FiveMConfigEntry) -> bool: """Set up FiveM from a config entry.""" _LOGGER.debug( "Create FiveM server instance for '%s:%s'", @@ -27,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PORT], ) - coordinator = FiveMDataUpdateCoordinator(hass, entry.data, entry.entry_id) + coordinator = FiveMDataUpdateCoordinator(hass, entry) try: await coordinator.initialize() @@ -36,16 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(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: FiveMConfigEntry) -> 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/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py index de58ea52fb6..42119939d4a 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -7,11 +7,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, NAME_STATUS +from .const import NAME_STATUS +from .coordinator import FiveMConfigEntry from .entity import FiveMEntity, FiveMEntityDescription @@ -33,11 +33,11 @@ BINARY_SENSORS: tuple[FiveMBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FiveMConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FiveM binary sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [FiveMSensorEntity(coordinator, description) for description in BINARY_SENSORS] diff --git a/homeassistant/components/fivem/coordinator.py b/homeassistant/components/fivem/coordinator.py index 1fdf87fb2b7..2fcad7e0c98 100644 --- a/homeassistant/components/fivem/coordinator.py +++ b/homeassistant/components/fivem/coordinator.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import Mapping from datetime import timedelta import logging from typing import Any from fivem import FiveM, FiveMServerOfflineError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,26 +26,32 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type FiveMConfigEntry = ConfigEntry[FiveMDataUpdateCoordinator] + class FiveMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching FiveM data.""" - def __init__( - self, hass: HomeAssistant, config_data: Mapping[str, Any], unique_id: str - ) -> None: + def __init__(self, hass: HomeAssistant, entry: FiveMConfigEntry) -> None: """Initialize server instance.""" - self.unique_id = unique_id + self.unique_id = entry.entry_id self.server = None self.version = None self.game_name: str | None = None - self.host = config_data[CONF_HOST] + self.host = entry.data[CONF_HOST] - self._fivem = FiveM(self.host, config_data[CONF_PORT]) + self._fivem = FiveM(self.host, entry.data[CONF_PORT]) update_interval = timedelta(seconds=SCAN_INTERVAL) - 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 initialize(self) -> None: """Initialize the FiveM server.""" diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index b63f3b9082f..88290171756 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -11,7 +10,6 @@ from homeassistant.helpers.typing import StateType from .const import ( ATTR_PLAYERS_LIST, ATTR_RESOURCES_LIST, - DOMAIN, NAME_PLAYERS_MAX, NAME_PLAYERS_ONLINE, NAME_RESOURCES, @@ -19,6 +17,7 @@ from .const import ( UNIT_PLAYERS_ONLINE, UNIT_RESOURCES, ) +from .coordinator import FiveMConfigEntry from .entity import FiveMEntity, FiveMEntityDescription @@ -50,11 +49,11 @@ SENSORS: tuple[FiveMSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FiveMConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FiveM sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # Add sensor entities. async_add_entities( From bdc847c7ac24c2de7ab81f71fb45fdd6c28ccea2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:30:13 +0100 Subject: [PATCH 244/410] Use runtime_data in firmata (#137630) --- homeassistant/components/firmata/__init__.py | 21 ++++++++++--------- .../components/firmata/binary_sensor.py | 8 +++---- homeassistant/components/firmata/light.py | 7 ++++--- homeassistant/components/firmata/sensor.py | 8 +++---- homeassistant/components/firmata/switch.py | 8 +++---- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index 26fbe596aa8..45cae276967 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -122,6 +122,8 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.All(cv.ensure_list, [BOARD_CONFIG_SCHEMA])}, extra=vol.ALLOW_EXTRA ) +type FirmataConfigEntry = ConfigEntry[FirmataBoard] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Firmata domain.""" @@ -158,11 +160,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: FirmataConfigEntry +) -> bool: """Set up a Firmata board for a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - _LOGGER.debug( "Setting up Firmata id %s, name %s, config %s", config_entry.entry_id, @@ -175,13 +176,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not await board.async_setup(): return False - hass.data[DOMAIN][config_entry.entry_id] = board + config_entry.runtime_data = board async def handle_shutdown(event) -> None: """Handle shutdown of board when Home Assistant shuts down.""" - # Ensure board was not already removed previously before shutdown - if config_entry.entry_id in hass.data[DOMAIN]: - await board.async_reset() + await board.async_reset() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_shutdown) @@ -208,7 +207,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: FirmataConfigEntry +) -> bool: """Shutdown and close a Firmata board for a config entry.""" _LOGGER.debug("Closing Firmata board %s", config_entry.data[CONF_NAME]) results: list[bool] = [] @@ -220,6 +221,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> results.append( await hass.config_entries.async_unload_platforms(config_entry, platforms) ) - results.append(await hass.data[DOMAIN].pop(config_entry.entry_id).async_reset()) + results.append(await config_entry.runtime_data.async_reset()) return False not in results diff --git a/homeassistant/components/firmata/binary_sensor.py b/homeassistant/components/firmata/binary_sensor.py index c25a61ddac7..4973afa6960 100644 --- a/homeassistant/components/firmata/binary_sensor.py +++ b/homeassistant/components/firmata/binary_sensor.py @@ -3,12 +3,12 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_NEGATE_STATE, CONF_PIN_MODE, DOMAIN +from . import FirmataConfigEntry +from .const import CONF_NEGATE_STATE, CONF_PIN_MODE from .entity import FirmataPinEntity from .pin import FirmataBinaryDigitalInput, FirmataPinUsedException @@ -17,13 +17,13 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FirmataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Firmata binary sensors.""" new_entities = [] - board = hass.data[DOMAIN][config_entry.entry_id] + board = config_entry.runtime_data for binary_sensor in board.binary_sensors: pin = binary_sensor[CONF_PIN] pin_mode = binary_sensor[CONF_PIN_MODE] diff --git a/homeassistant/components/firmata/light.py b/homeassistant/components/firmata/light.py index 00453762c14..4f27143b774 100644 --- a/homeassistant/components/firmata/light.py +++ b/homeassistant/components/firmata/light.py @@ -11,8 +11,9 @@ from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import FirmataConfigEntry from .board import FirmataPinType -from .const import CONF_INITIAL_STATE, CONF_PIN_MODE, DOMAIN +from .const import CONF_INITIAL_STATE, CONF_PIN_MODE from .entity import FirmataPinEntity from .pin import FirmataBoardPin, FirmataPinUsedException, FirmataPWMOutput @@ -21,13 +22,13 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FirmataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Firmata lights.""" new_entities = [] - board = hass.data[DOMAIN][config_entry.entry_id] + board = config_entry.runtime_data for light in board.lights: pin = light[CONF_PIN] pin_mode = light[CONF_PIN_MODE] diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py index 32559b9197d..569d97fe1ec 100644 --- a/homeassistant/components/firmata/sensor.py +++ b/homeassistant/components/firmata/sensor.py @@ -3,12 +3,12 @@ import logging from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DIFFERENTIAL, CONF_PIN_MODE, DOMAIN +from . import FirmataConfigEntry +from .const import CONF_DIFFERENTIAL, CONF_PIN_MODE from .entity import FirmataPinEntity from .pin import FirmataAnalogInput, FirmataPinUsedException @@ -17,13 +17,13 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FirmataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Firmata sensors.""" new_entities = [] - board = hass.data[DOMAIN][config_entry.entry_id] + board = config_entry.runtime_data for sensor in board.sensors: pin = sensor[CONF_PIN] pin_mode = sensor[CONF_PIN_MODE] diff --git a/homeassistant/components/firmata/switch.py b/homeassistant/components/firmata/switch.py index 4203b221d4f..33953b78974 100644 --- a/homeassistant/components/firmata/switch.py +++ b/homeassistant/components/firmata/switch.py @@ -4,12 +4,12 @@ import logging from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_INITIAL_STATE, CONF_NEGATE_STATE, CONF_PIN_MODE, DOMAIN +from . import FirmataConfigEntry +from .const import CONF_INITIAL_STATE, CONF_NEGATE_STATE, CONF_PIN_MODE from .entity import FirmataPinEntity from .pin import FirmataBinaryDigitalOutput, FirmataPinUsedException @@ -18,13 +18,13 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FirmataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Firmata switches.""" new_entities = [] - board = hass.data[DOMAIN][config_entry.entry_id] + board = config_entry.runtime_data for switch in board.switches: pin = switch[CONF_PIN] pin_mode = switch[CONF_PIN_MODE] From d9726ab08c705fd80f73e794273c2f9cc54c9c8b Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 7 Feb 2025 11:32:47 +0100 Subject: [PATCH 245/410] Use snapshots for ConfigEntry migration tests (#136093) * Add snapshots for migration * Reduce fixtures specific to migration * Explicitly test versions of migrated entries --- .../lcn/fixtures/config_entry_pchk_v1_1.json | 217 ------------------ .../lcn/fixtures/config_entry_pchk_v1_2.json | 184 +-------------- tests/components/lcn/snapshots/test_init.ambr | 140 +++++++++++ tests/components/lcn/test_init.py | 11 +- 4 files changed, 148 insertions(+), 404 deletions(-) create mode 100644 tests/components/lcn/snapshots/test_init.ambr diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json b/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json index e1893c30b42..7dea4405fc5 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json +++ b/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json @@ -13,13 +13,6 @@ "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 - }, - { - "address": [0, 5, true], - "name": "TestGroup", - "hardware_serial": -1, - "software_serial": -1, - "hardware_type": -1 } ], "entities": [ @@ -33,216 +26,6 @@ "dimmable": true, "transition": 5000.0 } - }, - { - "address": [0, 7, false], - "name": "Light_Output2", - "resource": "output2", - "domain": "light", - "domain_data": { - "output": "OUTPUT2", - "dimmable": false, - "transition": 0 - } - }, - { - "address": [0, 7, false], - "name": "Light_Relay1", - "resource": "relay1", - "domain": "light", - "domain_data": { - "output": "RELAY1", - "dimmable": false, - "transition": 0.0 - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output1", - "resource": "output1", - "domain": "switch", - "domain_data": { - "output": "OUTPUT1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output2", - "resource": "output2", - "domain": "switch", - "domain_data": { - "output": "OUTPUT2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay1", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay2", - "resource": "relay2", - "domain": "switch", - "domain_data": { - "output": "RELAY2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Regulator1", - "resource": "r1varsetpoint", - "domain": "switch", - "domain_data": { - "output": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Switch_KeyLock1", - "resource": "a1", - "domain": "switch", - "domain_data": { - "output": "A1" - } - }, - { - "address": [0, 5, true], - "name": "Switch_Group5", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Outputs", - "resource": "outputs", - "domain": "cover", - "domain_data": { - "motor": "OUTPUTS", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Relays", - "resource": "motor1", - "domain": "cover", - "domain_data": { - "motor": "MOTOR1", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Climate1", - "resource": "var1.r1varsetpoint", - "domain": "climate", - "domain_data": { - "source": "VAR1", - "setpoint": "R1VARSETPOINT", - "lockable": true, - "min_temp": 0.0, - "max_temp": 40.0, - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Romantic", - "resource": "0.0", - "domain": "scene", - "domain_data": { - "register": 0, - "scene": 0, - "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], - "transition": null - } - }, - { - "address": [0, 7, false], - "name": "Romantic Transition", - "resource": "0.1", - "domain": "scene", - "domain_data": { - "register": 0, - "scene": 1, - "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], - "transition": 10000 - } - }, - { - "address": [0, 7, false], - "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", - "domain": "binary_sensor", - "domain_data": { - "source": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Binary_Sensor1", - "resource": "binsensor1", - "domain": "binary_sensor", - "domain_data": { - "source": "BINSENSOR1" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_KeyLock", - "resource": "a5", - "domain": "binary_sensor", - "domain_data": { - "source": "A5" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Var1", - "resource": "var1", - "domain": "sensor", - "domain_data": { - "source": "VAR1", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", - "domain": "sensor", - "domain_data": { - "source": "R1VARSETPOINT", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Led6", - "resource": "led6", - "domain": "sensor", - "domain_data": { - "source": "LED6", - "unit_of_measurement": "NATIVE" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_LogicOp1", - "resource": "logicop1", - "domain": "sensor", - "domain_data": { - "source": "LOGICOP1", - "unit_of_measurement": "NATIVE" - } } ] } diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json b/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json index 7389079dca9..4cade6b64d0 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json +++ b/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json @@ -14,13 +14,6 @@ "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 - }, - { - "address": [0, 5, true], - "name": "TestGroup", - "hardware_serial": -1, - "software_serial": -1, - "hardware_type": -1 } ], "entities": [ @@ -43,115 +36,7 @@ "domain_data": { "output": "OUTPUT2", "dimmable": false, - "transition": 0 - } - }, - { - "address": [0, 7, false], - "name": "Light_Relay1", - "resource": "relay1", - "domain": "light", - "domain_data": { - "output": "RELAY1", - "dimmable": false, - "transition": 0.0 - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output1", - "resource": "output1", - "domain": "switch", - "domain_data": { - "output": "OUTPUT1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output2", - "resource": "output2", - "domain": "switch", - "domain_data": { - "output": "OUTPUT2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay1", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay2", - "resource": "relay2", - "domain": "switch", - "domain_data": { - "output": "RELAY2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Regulator1", - "resource": "r1varsetpoint", - "domain": "switch", - "domain_data": { - "output": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Switch_KeyLock1", - "resource": "a1", - "domain": "switch", - "domain_data": { - "output": "A1" - } - }, - { - "address": [0, 5, true], - "name": "Switch_Group5", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Outputs", - "resource": "outputs", - "domain": "cover", - "domain_data": { - "motor": "OUTPUTS", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Relays", - "resource": "motor1", - "domain": "cover", - "domain_data": { - "motor": "MOTOR1", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Climate1", - "resource": "var1.r1varsetpoint", - "domain": "climate", - "domain_data": { - "source": "VAR1", - "setpoint": "R1VARSETPOINT", - "lockable": true, - "min_temp": 0.0, - "max_temp": 40.0, - "unit_of_measurement": "°C" + "transition": null } }, { @@ -177,73 +62,6 @@ "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], "transition": 10000 } - }, - { - "address": [0, 7, false], - "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", - "domain": "binary_sensor", - "domain_data": { - "source": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Binary_Sensor1", - "resource": "binsensor1", - "domain": "binary_sensor", - "domain_data": { - "source": "BINSENSOR1" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_KeyLock", - "resource": "a5", - "domain": "binary_sensor", - "domain_data": { - "source": "A5" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Var1", - "resource": "var1", - "domain": "sensor", - "domain_data": { - "source": "VAR1", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", - "domain": "sensor", - "domain_data": { - "source": "R1VARSETPOINT", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Led6", - "resource": "led6", - "domain": "sensor", - "domain_data": { - "source": "LED6", - "unit_of_measurement": "NATIVE" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_LogicOp1", - "resource": "logicop1", - "domain": "sensor", - "domain_data": { - "source": "LOGICOP1", - "unit_of_measurement": "NATIVE" - } } ] } diff --git a/tests/components/lcn/snapshots/test_init.ambr b/tests/components/lcn/snapshots/test_init.ambr new file mode 100644 index 00000000000..ea6267aaa0b --- /dev/null +++ b/tests/components/lcn/snapshots/test_init.ambr @@ -0,0 +1,140 @@ +# serializer version: 1 +# name: test_migrate_1_1 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + 'resource': 'output1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- +# name: test_migrate_1_2 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + 'resource': 'output1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': False, + 'output': 'OUTPUT2', + 'transition': 0.0, + }), + 'name': 'Light_Output2', + 'resource': 'output2', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 0, + 'transition': 0.0, + }), + 'name': 'Romantic', + 'resource': '0.0', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 1, + 'transition': 10.0, + }), + 'name': 'Romantic Transition', + 'resource': '0.1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 4bb8d023d3f..ef3c2d3cb66 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -11,6 +11,7 @@ from pypck.connection import ( ) from pypck.lcn_defs import LcnEvent import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.lcn.const import DOMAIN @@ -134,7 +135,7 @@ async def test_async_entry_reload_on_host_event_received( @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: +async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_1 = create_config_entry("pchk_v1_1", version=(1, 1)) entry_v1_1.add_to_hass(hass) @@ -143,14 +144,15 @@ async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: await hass.async_block_till_done() entry_migrated = hass.config_entries.async_get_entry(entry_v1_1.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED assert entry_migrated.version == 2 assert entry_migrated.minor_version == 1 - assert entry_migrated.data == entry.data + assert entry_migrated.data == snapshot @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_migrate_1_2(hass: HomeAssistant, entry) -> None: +async def test_migrate_1_2(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_2 = create_config_entry("pchk_v1_2", version=(1, 2)) entry_v1_2.add_to_hass(hass) @@ -159,7 +161,8 @@ async def test_migrate_1_2(hass: HomeAssistant, entry) -> None: await hass.async_block_till_done() entry_migrated = hass.config_entries.async_get_entry(entry_v1_2.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED assert entry_migrated.version == 2 assert entry_migrated.minor_version == 1 - assert entry_migrated.data == entry.data + assert entry_migrated.data == snapshot From 4c9127a0ea1d0ac5c74018d64d6d17f2d98295c5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 7 Feb 2025 12:28:44 +0100 Subject: [PATCH 246/410] Remove unnecessary type casts (#137657) --- homeassistant/components/command_line/binary_sensor.py | 3 --- homeassistant/components/command_line/cover.py | 3 +-- homeassistant/components/command_line/notify.py | 3 +-- homeassistant/components/command_line/sensor.py | 4 +--- homeassistant/components/command_line/switch.py | 3 +-- homeassistant/components/mealie/services.py | 2 +- homeassistant/components/nws/weather.py | 4 +--- 7 files changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f5d9ad9d63d..fab56ae6887 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -from typing import cast from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import ( @@ -43,9 +42,7 @@ async def async_setup_platform( if not discovery_info: return - discovery_info = cast(DiscoveryInfoType, discovery_info) binary_sensor_config = discovery_info - command: str = binary_sensor_config[CONF_COMMAND] payload_off: str = binary_sensor_config[CONF_PAYLOAD_OFF] payload_on: str = binary_sensor_config[CONF_PAYLOAD_ON] diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 8ddfd399ba8..7f1bc12264c 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from homeassistant.components.cover import CoverEntity from homeassistant.const import ( @@ -41,7 +41,6 @@ async def async_setup_platform( return covers = [] - discovery_info = cast(DiscoveryInfoType, discovery_info) entities: dict[str, dict[str, Any]] = { slugify(discovery_info[CONF_NAME]): discovery_info } diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 4f5a4e4b499..ec1b51a47c7 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import subprocess -from typing import Any, cast +from typing import Any from homeassistant.components.notify import BaseNotificationService from homeassistant.const import CONF_COMMAND @@ -26,7 +26,6 @@ def get_service( if not discovery_info: return None - discovery_info = cast(DiscoveryInfoType, discovery_info) notify_config = discovery_info command: str = notify_config[CONF_COMMAND] timeout: int = notify_config[CONF_COMMAND_TIMEOUT] diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index e4c1370d5f7..b7c36a005fa 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Mapping from datetime import datetime, timedelta import json -from typing import Any, cast +from typing import Any from jsonpath import jsonpath @@ -51,9 +51,7 @@ async def async_setup_platform( if not discovery_info: return - discovery_info = cast(DiscoveryInfoType, discovery_info) sensor_config = discovery_info - command: str = sensor_config[CONF_COMMAND] command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT] json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index e42c2226cf2..31400048ddc 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ( @@ -40,7 +40,6 @@ async def async_setup_platform( return switches = [] - discovery_info = cast(DiscoveryInfoType, discovery_info) entities: dict[str, dict[str, Any]] = { slugify(discovery_info[CONF_NAME]): discovery_info } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index ca8c28f9d13..15e3348adbe 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -128,7 +128,7 @@ def setup_services(hass: HomeAssistant) -> None: translation_domain=DOMAIN, translation_key="end_date_before_start_date", ) - client = cast(MealieConfigEntry, entry).runtime_data.client + client = entry.runtime_data.client try: mealplans = await client.get_mealplans(start_date, end_date) except MealieConnectionError as err: diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 3c7393aa184..d34a5abe8af 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import partial from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast +from typing import Any, Required, TypedDict, cast import voluptuous as vol @@ -177,8 +177,6 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) for forecast_type in ("twice_daily", "hourly"): if (coordinator := self.forecast_coordinators[forecast_type]) is None: continue - if TYPE_CHECKING: - forecast_type = cast(Literal["twice_daily", "hourly"], forecast_type) self.unsub_forecast[forecast_type] = coordinator.async_add_listener( partial(self._handle_forecast_update, forecast_type) ) From d6d9c9f01a386d48a4105106d8aa3a6af78c6ce1 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 7 Feb 2025 12:49:45 +0100 Subject: [PATCH 247/410] Bump PyTado to version 0.18.6 (#137655) --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 856a0c5402b..b83e2695137 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.5"] + "requirements": ["python-tado==0.18.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c3c2b42424..56e7ddf173a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2464,7 +2464,7 @@ python-smarttub==0.0.38 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.5 +python-tado==0.18.6 # homeassistant.components.technove python-technove==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef9b2978459..f051dde3162 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1997,7 +1997,7 @@ python-smarttub==0.0.38 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.5 +python-tado==0.18.6 # homeassistant.components.technove python-technove==1.3.1 From da0481852ee467a9082083174a85c30625748c2c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Feb 2025 13:05:32 +0100 Subject: [PATCH 248/410] Make all occurrences of "Home Guard" in lg_thinq consistent (#137662) LQ uses "Home Guard" as a trademark, so all occurrences in the integration should be consistent in spelling. --- homeassistant/components/lg_thinq/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 8f498e0f8a2..dee2d21e05a 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -215,9 +215,9 @@ "error_during_washing": "An error has occurred in the washing machine", "error_has_occurred": "An error has occurred", "frozen_is_complete": "Ice plus is done", - "homeguard_is_stopped": "Home guard has stopped", + "homeguard_is_stopped": "Home Guard has stopped", "lack_of_water": "There is no water in the water tank", - "motion_is_detected": "Photograph is sent as movement is detected during home guard", + "motion_is_detected": "Photograph is sent as movement is detected during Home Guard", "need_to_check_location": "Location check is required", "pollution_is_high": "Air status is rapidly becoming bad", "preheating_is_complete": "Preheating is done", @@ -432,9 +432,9 @@ "lock": "Control lock", "macrosector": "Remote is in use", "melting": "Wort dissolving", - "monitoring_detecting": "HomeGuard is active", + "monitoring_detecting": "Home Guard is active", "monitoring_moving": "Going to the starting point", - "monitoring_positioning": "Setting homeguard start point", + "monitoring_positioning": "Setting Home Guard start point", "night_dry": "Night dry", "oven_setting": "Cooktop connected", "pause": "[%key:common::state::paused%]", From e340f5af8d2e3999eb0bbe890a38a63280ac7afa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 13:06:13 +0100 Subject: [PATCH 249/410] Use runtime_data in flume (#137660) --- homeassistant/components/flume/__init__.py | 50 +++++++------------ .../components/flume/binary_sensor.py | 13 ++--- homeassistant/components/flume/const.py | 6 --- homeassistant/components/flume/coordinator.py | 16 ++++++ homeassistant/components/flume/sensor.py | 17 +++---- 5 files changed, 46 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index d91c6b175cf..9da0e5163c5 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -7,7 +7,7 @@ from requests import Session from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -24,16 +24,12 @@ from homeassistant.core import ( from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.selector import ConfigEntrySelector -from .const import ( - BASE_TOKEN_FILENAME, - DOMAIN, - FLUME_AUTH, - FLUME_DEVICES, - FLUME_HTTP_SESSION, - FLUME_NOTIFICATIONS_COORDINATOR, - PLATFORMS, +from .const import BASE_TOKEN_FILENAME, DOMAIN, PLATFORMS +from .coordinator import ( + FlumeConfigEntry, + FlumeNotificationDataUpdateCoordinator, + FlumeRuntimeData, ) -from .coordinator import FlumeNotificationDataUpdateCoordinator SERVICE_LIST_NOTIFICATIONS = "list_notifications" CONF_CONFIG_ENTRY = "config_entry" @@ -76,7 +72,7 @@ def _setup_entry( return flume_auth, flume_devices, http_session -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FlumeConfigEntry) -> bool: """Set up flume from a config entry.""" flume_auth, flume_devices, http_session = await hass.async_add_executor_job( @@ -86,12 +82,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass=hass, auth=flume_auth ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - FLUME_DEVICES: flume_devices, - FLUME_AUTH: flume_auth, - FLUME_HTTP_SESSION: http_session, - FLUME_NOTIFICATIONS_COORDINATOR: notification_coordinator, - } + entry.runtime_data = FlumeRuntimeData( + devices=flume_devices, + auth=flume_auth, + http_session=http_session, + notifications_coordinator=notification_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) setup_service(hass) @@ -99,16 +95,10 @@ 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: FlumeConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][FLUME_HTTP_SESSION].close() - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + entry.runtime_data.http_session.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def setup_service(hass: HomeAssistant) -> None: @@ -118,15 +108,13 @@ def setup_service(hass: HomeAssistant) -> None: def list_notifications(call: ServiceCall) -> ServiceResponse: """Return the user notifications.""" entry_id: str = call.data[CONF_CONFIG_ENTRY] - entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + entry: FlumeConfigEntry | None = hass.config_entries.async_get_entry(entry_id) if not entry: raise ValueError(f"Invalid config entry: {entry_id}") - if not (flume_domain_data := hass.data[DOMAIN].get(entry_id)): + if not entry.state == ConfigEntryState.LOADED: raise ValueError(f"Config entry not loaded: {entry_id}") return { - "notifications": flume_domain_data[ - FLUME_NOTIFICATIONS_COORDINATOR - ].notifications + "notifications": entry.runtime_data.notifications_coordinator.notifications # type: ignore[dict-item] } hass.services.async_register( diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 28f56168d9c..67cb71c5767 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -9,15 +9,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - DOMAIN, - FLUME_DEVICES, - FLUME_NOTIFICATIONS_COORDINATOR, FLUME_TYPE_BRIDGE, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, @@ -29,6 +25,7 @@ from .const import ( NOTIFICATION_LOW_BATTERY, ) from .coordinator import ( + FlumeConfigEntry, FlumeDeviceConnectionUpdateCoordinator, FlumeNotificationDataUpdateCoordinator, ) @@ -71,12 +68,12 @@ FLUME_BINARY_NOTIFICATION_SENSORS: tuple[FlumeBinarySensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlumeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Flume binary sensor..""" - flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] - flume_devices = flume_domain_data[FLUME_DEVICES] + flume_domain_data = config_entry.runtime_data + flume_devices = flume_domain_data.devices flume_entity_list: list[ FlumeNotificationBinarySensor | FlumeConnectionBinarySensor @@ -85,7 +82,7 @@ async def async_setup_entry( connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( hass=hass, flume_devices=flume_devices ) - notification_coordinator = flume_domain_data[FLUME_NOTIFICATIONS_COORDINATOR] + notification_coordinator = flume_domain_data.notifications_coordinator flume_devices = get_valid_flume_devices(flume_devices) for device in flume_devices: device_id = device[KEY_DEVICE_ID] diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 1f9fc10b1b3..a8fe21f4b06 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -26,12 +26,6 @@ _LOGGER = logging.getLogger(__package__) FLUME_TYPE_BRIDGE = 1 FLUME_TYPE_SENSOR = 2 - -FLUME_AUTH = "flume_auth" -FLUME_HTTP_SESSION = "http_session" -FLUME_DEVICES = "devices" -FLUME_NOTIFICATIONS_COORDINATOR = "notifications_coordinator" - CONF_TOKEN_FILE = "token_filename" BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE" diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 30e7962304c..fc76600cad4 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -2,11 +2,14 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any import pyflume from pyflume import FlumeAuth, FlumeData, FlumeDeviceList +from requests import Session +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,6 +22,19 @@ from .const import ( ) +@dataclass +class FlumeRuntimeData: + """Runtime data for the Flume config entry.""" + + devices: FlumeDeviceList + auth: FlumeAuth + http_session: Session + notifications_coordinator: FlumeNotificationDataUpdateCoordinator + + +type FlumeConfigEntry = ConfigEntry[FlumeRuntimeData] + + class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for an individual flume device.""" diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 96395e5403f..6c7cc0ab37d 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,10 +18,6 @@ from homeassistant.helpers.typing import StateType from .const import ( DEVICE_SCAN_INTERVAL, - DOMAIN, - FLUME_AUTH, - FLUME_DEVICES, - FLUME_HTTP_SESSION, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, KEY_DEVICE_LOCATION, @@ -30,7 +25,7 @@ from .const import ( KEY_DEVICE_LOCATION_TIMEZONE, KEY_DEVICE_TYPE, ) -from .coordinator import FlumeDeviceDataUpdateCoordinator +from .coordinator import FlumeConfigEntry, FlumeDeviceDataUpdateCoordinator from .entity import FlumeEntity from .util import get_valid_flume_devices @@ -112,15 +107,15 @@ def make_flume_datas( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlumeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Flume sensor.""" - flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] - flume_devices = flume_domain_data[FLUME_DEVICES] - flume_auth: FlumeAuth = flume_domain_data[FLUME_AUTH] - http_session: Session = flume_domain_data[FLUME_HTTP_SESSION] + flume_domain_data = config_entry.runtime_data + flume_devices = flume_domain_data.devices + flume_auth = flume_domain_data.auth + http_session = flume_domain_data.http_session flume_devices = [ device for device in get_valid_flume_devices(flume_devices) From 6d6961ae6ef2ec0624ee4388da37e54803f817df Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 7 Feb 2025 14:44:44 +0100 Subject: [PATCH 250/410] Clean up colliding deleted devices when updating non-deleted devices (#135592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Schrödinger's devices * Address feedback * Add comment with broader context --- homeassistant/helpers/device_registry.py | 42 ++++++++++++++++++++++-- tests/helpers/test_device_registry.py | 33 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 975b4a2aec9..92101dd0e21 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from datetime import datetime from enum import StrEnum from functools import lru_cache @@ -561,6 +561,21 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( return self._connections[connection] return None + def get_entries( + self, + identifiers: set[tuple[str, str]] | None, + connections: set[tuple[str, str]] | None, + ) -> Iterable[_EntryTypeT]: + """Get entries from identifiers or connections.""" + if identifiers: + for identifier in identifiers: + if identifier in self._identifiers: + yield self._identifiers[identifier] + if connections: + for connection in _normalize_connections(connections): + if connection in self._connections: + yield self._connections[connection] + class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): """Container for active (non-deleted) device registry entries.""" @@ -667,6 +682,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Check if device is deleted.""" return self.deleted_devices.get_entry(identifiers, connections) + def _async_get_deleted_devices( + self, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, + ) -> Iterable[DeletedDeviceEntry]: + """List devices that are deleted.""" + return self.deleted_devices.get_entries(identifiers, connections) + def _substitute_name_placeholders( self, domain: str, @@ -958,6 +981,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries + added_connections: set[tuple[str, str]] | None = None + added_identifiers: set[tuple[str, str]] | None = None + if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, @@ -966,6 +992,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) old_connections = old.connections if not normalized_connections.issubset(old_connections): + added_connections = normalized_connections new_values["connections"] = old_connections | normalized_connections old_values["connections"] = old_connections @@ -975,17 +1002,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) old_identifiers = old.identifiers if not merge_identifiers.issubset(old_identifiers): + added_identifiers = merge_identifiers new_values["identifiers"] = old_identifiers | merge_identifiers old_values["identifiers"] = old_identifiers if new_connections is not UNDEFINED: - new_values["connections"] = self._validate_connections( + added_connections = new_values["connections"] = self._validate_connections( device_id, new_connections, False ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: - new_values["identifiers"] = self._validate_identifiers( + added_identifiers = new_values["identifiers"] = self._validate_identifiers( device_id, new_identifiers, False ) old_values["identifiers"] = old.identifiers @@ -1028,6 +1056,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new = attr.evolve(old, **new_values) self.devices[device_id] = new + # NOTE: Once we solve the broader issue of duplicated devices, we might + # want to revisit it. Instead of simply removing the duplicated deleted device, + # we might want to merge the information from it into the non-deleted device. + for deleted_device in self._async_get_deleted_devices( + added_identifiers, added_connections + ): + del self.deleted_devices[deleted_device.id] + # If its only run time attributes (suggested_area) # that do not get saved we do not want to write # to disk or fire an event as we would end up diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 08b984a0477..be4ace87894 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -3378,6 +3378,39 @@ async def test_device_registry_identifiers_collision( assert not device1_refetched.identifiers.isdisjoint(device3_refetched.identifiers) +async def test_device_registry_deleted_device_collision( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test update collisions with deleted devices in the device registry.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, + manufacturer="manufacturer", + model="model", + ) + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(device1.id) + assert len(device_registry.deleted_devices) == 1 + + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert len(device_registry.deleted_devices) == 1 + + device_registry.async_update_device( + device2.id, + merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, + ) + assert len(device_registry.deleted_devices) == 0 + + async def test_primary_config_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 239f2dcb3e5ab8cb297908aa55850fe07fac2a1d Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 7 Feb 2025 16:25:09 +0200 Subject: [PATCH 251/410] Fix LG webOS TV turn off when device is already off (#137675) --- homeassistant/components/webostv/media_player.py | 2 +- tests/components/webostv/test_media_player.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 076b6caad24..5c47a5e775f 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -125,7 +125,7 @@ def cmd[_R, **_P]( self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs ) -> _R: """Wrap all command methods.""" - if self.state is MediaPlayerState.OFF: + if self.state is MediaPlayerState.OFF and func.__name__ != "async_turn_off": raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_off", diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 820ab856ebb..679092efe3b 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -553,6 +553,17 @@ async def test_control_error_handling( assert client.play.call_count == int(is_on) +async def test_turn_off_when_device_is_off(hass: HomeAssistant, client) -> None: + """Test no error when turning off device that is already off.""" + await setup_webostv(hass) + client.is_on = False + await client.mock_state_update() + + data = {ATTR_ENTITY_ID: ENTITY_ID} + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, True) + assert client.power_off.call_count == 1 + + async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" client.sound_output = "lineout" From 600269ad0aac17a18282b8b46126bd4550af47a5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Feb 2025 15:33:38 +0100 Subject: [PATCH 252/410] Replace key names with friendly names in todoist actions (#137667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace key names with friendly names in todoist actions Also fix the grammar mistake in "A members username …" by rewordings. * Fix grammar in reminder_date and reminder_date_string descriptions * Fix description of due_date_string, replacing "date" with "time" According to the online docs this is not the day but the time when this is due. Just like the reminder_date_string. Example in the docs: "tomorrow at 14:00" --- homeassistant/components/todoist/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 721b491bbf5..68f22b51c47 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -55,7 +55,7 @@ }, "assignee": { "name": "Assignee", - "description": "A members username of a shared project to assign this task to." + "description": "The username of a shared project's member to assign this task to." }, "priority": { "name": "Priority", @@ -63,11 +63,11 @@ }, "due_date_string": { "name": "Due date string", - "description": "The day this task is due, in natural language." + "description": "The time this task is due, in natural language." }, "due_date_lang": { "name": "Due date language", - "description": "The language of due_date_string." + "description": "The language of 'Due date string'." }, "due_date": { "name": "Due date", @@ -75,15 +75,15 @@ }, "reminder_date_string": { "name": "Reminder date string", - "description": "When should user be reminded of this task, in natural language." + "description": "When the user should be reminded of this task, in natural language." }, "reminder_date_lang": { "name": "Reminder date language", - "description": "The language of reminder_date_string." + "description": "The language of 'Reminder date string'." }, "reminder_date": { "name": "Reminder date", - "description": "When should user be reminded of this task, in format YYYY-MM-DDTHH:MM:SS, in UTC timezone." + "description": "When the user should be reminded of this task, in format YYYY-MM-DDTHH:MM:SS, in UTC timezone." } } } From dd82212e4501da21944b488f6d5e57378e66f5b2 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:04:34 -0600 Subject: [PATCH 253/410] Handle previously migrated HEOS device identifier (#137596) --- homeassistant/components/heos/__init__.py | 19 ++++++++++++++--- tests/components/heos/test_init.py | 25 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index d735469c5cb..0c268b612ea 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -39,9 +39,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool ): for domain, player_id in device.identifiers: if domain == DOMAIN and not isinstance(player_id, str): - device_registry.async_update_device( # type: ignore[unreachable] - device.id, new_identifiers={(DOMAIN, str(player_id))} - ) + # Create set of identifiers excluding this integration + identifiers = { # type: ignore[unreachable] + (domain, identifier) + for domain, identifier in device.identifiers + if domain != DOMAIN + } + migrated_identifiers = {(DOMAIN, str(player_id))} + # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded + if not device_registry.async_get_device(migrated_identifiers): + identifiers.update(migrated_identifiers) + if len(identifiers) > 0: + device_registry.async_update_device( + device.id, new_identifiers=identifiers + ) + else: + device_registry.async_remove_device(device.id) break coordinator = HeosCoordinator(hass, entry) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 27dea82dcf2..81acb7b3b8b 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -193,13 +193,36 @@ async def test_device_id_migration( # Create a device with a legacy identifier device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] + identifiers={(DOMAIN, 1), ("Other", "1")}, # type: ignore[arg-type] ) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("Other", 1)}, # type: ignore[arg-type] ) assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) assert device_registry.async_get_device({("Other", 1)}) is not None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, "1")}) is not None + assert device_registry.async_get_device({("Other", "1")}) is not None + + +async def test_device_id_migration_both_present( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string devices are removed when both devices present.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier AND a new identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "1")} + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None From 040e1ff5fbf51d94fdc0d9d447604d43900e1ac1 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 7 Feb 2025 16:06:33 +0100 Subject: [PATCH 254/410] Use separate metadata files for onedrive (#137549) --- homeassistant/components/onedrive/__init__.py | 43 ++++++++- homeassistant/components/onedrive/backup.py | 95 ++++++++++++------- .../components/onedrive/strings.json | 3 + tests/components/onedrive/conftest.py | 10 +- tests/components/onedrive/const.py | 25 ++++- tests/components/onedrive/test_backup.py | 2 +- tests/components/onedrive/test_init.py | 37 ++++++++ 7 files changed, 178 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 5feefb2cf7d..9716f692ec8 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass +from html import unescape +from json import dumps, loads import logging from typing import cast @@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import ( HttpRequestException, OneDriveException, ) +from onedrive_personal_sdk.models.items import ItemUpdate from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN @@ -45,7 +48,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) async def get_access_token() -> str: @@ -89,6 +91,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder.id, ) + try: + await _migrate_backup_files(client, backup_folder.id) + except OneDriveException as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_files", + ) from err + _async_notify_backup_listeners_soon(hass) return True @@ -108,3 +118,34 @@ 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 _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None: + """Migrate backup files to metadata version 2.""" + files = await client.list_drive_items(backup_folder_id) + for file in files: + if file.description and '"metadata_version": 1' in ( + metadata_json := unescape(file.description) + ): + metadata = loads(metadata_json) + del metadata["metadata_version"] + metadata_filename = file.name.rsplit(".", 1)[0] + ".metadata.json" + metadata_file = await client.upload_file( + backup_folder_id, + metadata_filename, + dumps(metadata), # type: ignore[arg-type] + ) + metadata_description = { + "metadata_version": 2, + "backup_id": metadata["backup_id"], + "backup_file_id": file.id, + } + await client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + await client.update_drive_item( + path_or_id=file.id, + data=ItemUpdate(description=""), + ) + _LOGGER.debug("Migrated backup file %s", file.name) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 78bdcb24b8c..182e29aa63f 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -4,8 +4,8 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps -import html -import json +from html import unescape +from json import dumps, loads import logging from typing import Any, Concatenate @@ -34,6 +34,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours +METADATA_VERSION = 2 async def async_get_backup_agents( @@ -120,11 +121,19 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AsyncIterator[bytes]: """Download a backup file.""" - item = await self._find_item_by_backup_id(backup_id) - if item is None: + metadata_item = await self._find_item_by_backup_id(backup_id) + if ( + metadata_item is None + or metadata_item.description is None + or "backup_file_id" not in metadata_item.description + ): raise BackupAgentError("Backup not found") - stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT) + metadata_info = loads(unescape(metadata_item.description)) + + stream = await self._client.download_drive_item( + metadata_info["backup_file_id"], timeout=TIMEOUT + ) return stream.iter_chunked(1024) @handle_backup_errors @@ -136,15 +145,15 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - + filename = suggested_filename(backup) file = FileInfo( - suggested_filename(backup), + filename, backup.size, self._folder_id, await open_stream(), ) try: - item = await LargeFileUploadClient.upload( + backup_file = await LargeFileUploadClient.upload( self._token_function, file, session=async_get_clientsession(self._hass) ) except HashMismatchError as err: @@ -152,15 +161,25 @@ class OneDriveBackupAgent(BackupAgent): "Hash validation failed, backup file might be corrupt" ) from err - # store metadata in description - backup_dict = backup.as_dict() - backup_dict["metadata_version"] = 1 # version of the backup metadata - description = json.dumps(backup_dict) + # store metadata in metadata file + description = dumps(backup.as_dict()) _LOGGER.debug("Creating metadata: %s", description) + metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json" + metadata_file = await self._client.upload_file( + self._folder_id, + metadata_filename, + description, # type: ignore[arg-type] + ) + # add metadata to the metadata file + metadata_description = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_file_id": backup_file.id, + } await self._client.update_drive_item( - path_or_id=item.id, - data=ItemUpdate(description=description), + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), ) @handle_backup_errors @@ -170,18 +189,28 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - item = await self._find_item_by_backup_id(backup_id) - if item is None: + metadata_item = await self._find_item_by_backup_id(backup_id) + if ( + metadata_item is None + or metadata_item.description is None + or "backup_file_id" not in metadata_item.description + ): return - await self._client.delete_drive_item(item.id) + metadata_info = loads(unescape(metadata_item.description)) + + await self._client.delete_drive_item(metadata_info["backup_file_id"]) + await self._client.delete_drive_item(metadata_item.id) @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" + items = await self._client.list_drive_items(self._folder_id) 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 + await self._download_backup_metadata(item.id) + for item in items + if item.description + and "backup_id" in item.description + and f'"metadata_version": {METADATA_VERSION}' in unescape(item.description) ] @handle_backup_errors @@ -189,19 +218,11 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - item = await self._find_item_by_backup_id(backup_id) - return ( - self._backup_from_description(item.description) - if item and item.description - else None - ) + metadata_file = await self._find_item_by_backup_id(backup_id) + if metadata_file is None or metadata_file.description is None: + return None - def _backup_from_description(self, description: str) -> AgentBackup: - """Create a backup object from a description.""" - description = html.unescape( - description - ) # OneDrive encodes the description on save automatically - return AgentBackup.from_dict(json.loads(description)) + return await self._download_backup_metadata(metadata_file.id) async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None: """Find an item by backup ID.""" @@ -209,7 +230,15 @@ class OneDriveBackupAgent(BackupAgent): ( item for item in await self._client.list_drive_items(self._folder_id) - if item.description and backup_id in item.description + if item.description + and backup_id in item.description + and f'"metadata_version": {METADATA_VERSION}' + in unescape(item.description) ), None, ) + + async def _download_backup_metadata(self, item_id: str) -> AgentBackup: + metadata_stream = await self._client.download_drive_item(item_id) + metadata_json = loads(await metadata_stream.read()) + return AgentBackup.from_dict(metadata_json) diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 7686e83e2a5..ebc46d3eb12 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -35,6 +35,9 @@ }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" + }, + "failed_to_migrate_files": { + "message": "Failed to migrate metadata to separate files" } } } diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0d6ee09d587..8a0da9f584e 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,6 +1,7 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator +from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch @@ -15,11 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .const import ( + BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, MOCK_APPROOT, MOCK_BACKUP_FILE, MOCK_BACKUP_FOLDER, + MOCK_METADATA_FILE, ) from tests.common import MockConfigEntry @@ -89,13 +92,17 @@ def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[Magi 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.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] client.get_drive_item.return_value = MOCK_BACKUP_FILE + client.upload_file.return_value = MOCK_METADATA_FILE class MockStreamReader: async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: yield b"backup data" + async def read(self) -> bytes: + return dumps(BACKUP_METADATA).encode() + client.download_drive_item.return_value = MockStreamReader() return client @@ -107,6 +114,7 @@ def mock_large_file_upload_client() -> Generator[AsyncMock]: with patch( "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" ) as mock_upload: + mock_upload.return_value = MOCK_BACKUP_FILE yield mock_upload diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index ee3a5ce3dc4..3739369887d 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -72,6 +72,29 @@ MOCK_BACKUP_FILE = File( quick_xor_hash="hash", ), mime_type="application/x-tar", - description=escape(dumps(BACKUP_METADATA)), + description="", + created_by=CONTRIBUTOR, +) + +MOCK_METADATA_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( + { + "metadata_version": 2, + "backup_id": "23e64aec", + "backup_file_id": "id", + } + ) + ), created_by=CONTRIBUTOR, ) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 0277c3da02e..dd4f4d253d0 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -152,7 +152,7 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_onedrive_client.delete_drive_item.assert_called_once() + assert mock_onedrive_client.delete_drive_item.call_count == 2 async def test_agents_upload( diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index a6ad55442aa..7ceab98ff21 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,5 +1,7 @@ """Test the OneDrive setup.""" +from html import escape +from json import dumps from unittest.mock import MagicMock from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException @@ -9,6 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import setup_integration +from .const import BACKUP_METADATA, MOCK_BACKUP_FILE from tests.common import MockConfigEntry @@ -17,6 +20,7 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client_init: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) @@ -25,6 +29,10 @@ async def test_load_unload_config_entry( token_callback = mock_onedrive_client_init.call_args[0][0] assert await token_callback() == "mock-access-token" + # make sure metadata migration is not called + assert mock_onedrive_client.upload_file.call_count == 0 + assert mock_onedrive_client.update_drive_item.call_count == 0 + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) @@ -64,3 +72,32 @@ async def test_get_integration_folder_error( 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_migrate_metadata_files( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test migration of metadata files.""" + MOCK_BACKUP_FILE.description = escape( + dumps({**BACKUP_METADATA, "metadata_version": 1}) + ) + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.upload_file.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 2 + assert mock_onedrive_client.update_drive_item.call_args[1]["data"].description == "" + + +async def test_migrate_metadata_files_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test migration of metadata files errors.""" + mock_onedrive_client.list_drive_items.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 1d6aec44dfd365073a50e4bfe76799fd87daf5fa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 7 Feb 2025 16:30:58 +0100 Subject: [PATCH 255/410] Use config_entry.async_on_unload in forked_daapd (#137656) --- .../components/forked_daapd/__init__.py | 6 +----- homeassistant/components/forked_daapd/const.py | 1 - .../components/forked_daapd/media_player.py | 16 ++++++---------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index 6ff9a030a02..2172e60ba38 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, HASS_DATA_REMOVE_LISTENERS_KEY, HASS_DATA_UPDATER_KEY +from .const import DOMAIN, HASS_DATA_UPDATER_KEY PLATFORMS = [Platform.MEDIA_PLAYER] @@ -23,10 +23,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: HASS_DATA_UPDATER_KEY ].websocket_handler: websocket_handler.cancel() - for remove_listener in hass.data[DOMAIN][entry.entry_id][ - HASS_DATA_REMOVE_LISTENERS_KEY - ]: - remove_listener() del hass.data[DOMAIN][entry.entry_id] if not hass.data[DOMAIN]: del hass.data[DOMAIN] diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index 8d671f2fc07..dd7ed1bdf16 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -32,7 +32,6 @@ DEFAULT_TTS_VOLUME = 0.8 DEFAULT_UNMUTE_VOLUME = 0.6 DOMAIN = "forked_daapd" # key for hass.data FD_NAME = "OwnTone" -HASS_DATA_REMOVE_LISTENERS_KEY = "REMOVE_LISTENERS" HASS_DATA_UPDATER_KEY = "UPDATER" KNOWN_PIPES = {"librespot-java"} PIPE_FUNCTION_MAP = { diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index b8b544c1a2c..0116cc57e7b 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -58,7 +58,6 @@ from .const import ( DEFAULT_UNMUTE_VOLUME, DOMAIN, FD_NAME, - HASS_DATA_REMOVE_LISTENERS_KEY, HASS_DATA_UPDATER_KEY, KNOWN_PIPES, PIPE_FUNCTION_MAP, @@ -110,19 +109,16 @@ async def async_setup_entry( ForkedDaapdZone(api, output, config_entry.entry_id) for output in outputs ) - remove_add_zones_listener = async_dispatcher_connect( - hass, SIGNAL_ADD_ZONES.format(config_entry.entry_id), async_add_zones + config_entry.async_on_unload( + async_dispatcher_connect( + hass, SIGNAL_ADD_ZONES.format(config_entry.entry_id), async_add_zones + ) ) - remove_entry_listener = config_entry.add_update_listener(update_listener) + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) if not hass.data.get(DOMAIN): hass.data[DOMAIN] = {config_entry.entry_id: {}} - hass.data[DOMAIN][config_entry.entry_id] = { - HASS_DATA_REMOVE_LISTENERS_KEY: [ - remove_add_zones_listener, - remove_entry_listener, - ] - } + async_add_entities([forked_daapd_master], False) forked_daapd_updater = ForkedDaapdUpdater( hass, forked_daapd_api, config_entry.entry_id From b9dccb91255cfbe44f03b159b49cc9950a3886a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Feb 2025 16:32:03 +0100 Subject: [PATCH 256/410] Fix spelling of "SwitchBot", "ID" plus sentence-casing in switchbot (#137684) Fix spelling of "SwitchBot", "ID" and sentence-case "encryption" --- homeassistant/components/switchbot/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index fe44bc39e62..9c101204dcb 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -45,7 +45,7 @@ } }, "encrypted_choose_method": { - "description": "An encrypted SwitchBot device can be set up in Home Assistant in two different ways.\n\nYou can enter the key id and encryption key yourself, or Home Assistant can import them from your SwitchBot account.", + "description": "An encrypted SwitchBot device can be set up in Home Assistant in two different ways.\n\nYou can enter the key ID and encryption key yourself, or Home Assistant can import them from your SwitchBot account.", "menu_options": { "encrypted_auth": "SwitchBot account (recommended)", "encrypted_key": "Enter encryption key manually" @@ -53,7 +53,7 @@ } }, "error": { - "encryption_key_invalid": "Key ID or Encryption key is invalid", + "encryption_key_invalid": "Key ID or encryption key is invalid", "auth_failed": "Authentication failed: {error_detail}" }, "abort": { @@ -61,7 +61,7 @@ "no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.", "unknown": "[%key:common::config_flow::error::unknown%]", "api_error": "Error while communicating with SwitchBot API: {error_detail}", - "switchbot_unsupported_type": "Unsupported Switchbot Type." + "switchbot_unsupported_type": "Unsupported SwitchBot type." } }, "options": { From aa6fa3cdadd5fec214a6ca8c13539f29ae38b50a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 7 Feb 2025 16:32:28 +0100 Subject: [PATCH 257/410] Don't use the current temperature from Shelly BLU TRV as a state for External Temperature number entity (#137658) Introduce RpcBluTrvExtTempNumber for External Temperature entity --- homeassistant/components/shelly/number.py | 20 ++++++- .../shelly/snapshots/test_number.ambr | 2 +- tests/components/shelly/test_number.py | 56 ++++++++++++++++--- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index c4420783bbb..1fc47b23bdb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -139,6 +139,24 @@ class RpcBluTrvNumber(RpcNumber): ) +class RpcBluTrvExtTempNumber(RpcBluTrvNumber): + """Represent a RPC BluTrv External Temperature number.""" + + _reported_value: float | None = None + + @property + def native_value(self) -> float | None: + """Return value of number.""" + return self._reported_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + await super().async_set_native_value(value) + + self._reported_value = value + self.async_write_ha_state() + + NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", @@ -175,7 +193,7 @@ RPC_NUMBERS: Final = { "method": "Trv.SetExternalTemperature", "params": {"id": 0, "t_C": value}, }, - entity_class=RpcBluTrvNumber, + entity_class=RpcBluTrvExtTempNumber, ), "number": RpcNumberDescription( key="number", diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr index 965d44698c2..811101abe21 100644 --- a/tests/components/shelly/snapshots/test_number.ambr +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -52,7 +52,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15.2', + 'state': 'unknown', }) # --- # name: test_blu_trv_number_entity[number.trv_name_valve_position-entry] diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 15ed098093b..b1b65d99ab5 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -417,24 +417,23 @@ async def test_blu_trv_number_entity( assert entry == snapshot(name=f"{entity_id}-entry") -async def test_blu_trv_set_value( - hass: HomeAssistant, - mock_blu_trv: Mock, - monkeypatch: pytest.MonkeyPatch, +async def test_blu_trv_ext_temp_set_value( + hass: HomeAssistant, mock_blu_trv: Mock ) -> None: - """Test the set value action for BLU TRV number entity.""" + """Test the set value action for BLU TRV External Temperature number entity.""" await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" - assert hass.states.get(entity_id).state == "15.2" + # After HA start the state should be unknown because there was no previous external + # temperature report + assert hass.states.get(entity_id).state is STATE_UNKNOWN - monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "current_C", 22.2) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 22.2, }, blocking=True, @@ -451,3 +450,44 @@ async def test_blu_trv_set_value( ) assert hass.states.get(entity_id).state == "22.2" + + +async def test_blu_trv_valve_pos_set_value( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the set value action for BLU TRV Valve Position number entity.""" + # disable automatic temperature control to enable valve position entity + monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" + + assert hass.states.get(entity_id).state == "0" + + monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "pos", 20) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + mock_blu_trv.mock_update() + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetPosition", + "params": {"id": 0, "pos": 20}, + }, + BLU_TRV_TIMEOUT, + ) + # device only accepts int for 'pos' value + assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) + + assert hass.states.get(entity_id).state == "20" From b3205ea1cd4bb43ba31f04df1a879a9922e99d39 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:33:40 -0500 Subject: [PATCH 258/410] Do not rely on pyserial for port scanning with the CM5 + ZHA (#137585) Do not rely on pyserial for port scanning with the CM5 --- homeassistant/components/zha/config_flow.py | 11 ++++++++--- tests/components/zha/test_config_flow.py | 13 +++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index d41ae7dbfee..b98e53f98d8 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -113,9 +113,14 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: except HomeAssistantError: pass else: - yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1") - yellow_radio.description = "Yellow Zigbee module" - yellow_radio.manufacturer = "Nabu Casa" + # PySerial does not properly handle the Yellow's serial port with the CM5 + # so we manually include it + port = ListPortInfo(device="/dev/ttyAMA1", skip_link_detection=True) + port.description = "Yellow Zigbee module" + port.manufacturer = "Nabu Casa" + + ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")] + ports.insert(0, port) if is_hassio(hass): # Present the multi-PAN addon as a setup option, if it's available diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 573a04e9b57..94566be2f87 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1914,9 +1914,18 @@ async def test_options_flow_migration_reset_old_adapter( assert result4["step_id"] == "choose_serial_port" -async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "device", + [ + "/dev/ttyAMA1", # CM4 + "/dev/ttyAMA10", # CM5, erroneously detected by pyserial + ], +) +async def test_config_flow_port_yellow_port_name( + hass: HomeAssistant, device: str +) -> None: """Test config flow serial port name for Yellow Zigbee radio.""" - port = com_port(device="/dev/ttyAMA1") + port = com_port(device=device) port.serial_number = None port.manufacturer = None port.description = None From 6ff733b2250f1e92dbc6ec992329d75fc9dcf75b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 7 Feb 2025 16:36:26 +0100 Subject: [PATCH 259/410] Set the device class for the Shelly virtual sensor (#137068) * Map sensor role to the device class * Add test * Fix docstring --- homeassistant/components/shelly/const.py | 6 +++++ homeassistant/components/shelly/sensor.py | 14 ++++++++++- tests/components/shelly/test_sensor.py | 29 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index c8fa72606d6..d47f2b0ae80 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -28,6 +28,7 @@ from aioshelly.const import ( ) from homeassistant.components.number import NumberMode +from homeassistant.components.sensor import SensorDeviceClass DOMAIN: Final = "shelly" @@ -272,3 +273,8 @@ COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") # value confirmed by Shelly team BLU_TRV_TIMEOUT = 60 + +ROLE_TO_DEVICE_CLASS_MAP = { + "current_humidity": SensorDeviceClass.HUMIDITY, + "current_temperature": SensorDeviceClass.TEMPERATURE, +} diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 6d000556cf3..c492fc1de9e 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Final, cast @@ -38,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS +from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP, SHAIR_MAX_WORK_HOURS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -71,6 +72,8 @@ class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" + device_class_fn: Callable[[dict], SensorDeviceClass | None] | None = None + @dataclass(frozen=True, kw_only=True) class RestSensorDescription(RestEntityDescription, SensorEntityDescription): @@ -95,6 +98,12 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): if self.option_map: self._attr_options = list(self.option_map.values()) + if description.device_class_fn is not None: + if device_class := description.device_class_fn( + coordinator.device.config[key] + ): + self._attr_device_class = device_class + @property def native_value(self) -> StateType: """Return value of sensor.""" @@ -1266,6 +1275,9 @@ RPC_SENSORS: Final = { unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, + device_class_fn=lambda config: ROLE_TO_DEVICE_CLASS_MAP.get(config["role"]) + if "role" in config + else None, ), "enum": RpcSensorDescription( key="enum", diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 0bbb374012f..ef7771e53ba 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1426,3 +1426,32 @@ async def test_blu_trv_sensor_entity( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_device_virtual_number_sensor_with_device_class( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a virtual number sensor with device class for RPC device.""" + config = deepcopy(mock_rpc_device.config) + config["number:203"] = { + "name": "Current humidity", + "min": 0, + "max": 100, + "meta": {"ui": {"step": 1, "unit": "%", "view": "label"}}, + "role": "current_humidity", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:203"] = {"value": 34} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + state = hass.states.get("sensor.test_name_current_humidity") + assert state + assert state.state == "34" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY From 9a202b5e5613a65bd559183384dde1cb7a2c837b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Feb 2025 18:47:05 +0100 Subject: [PATCH 260/410] Fix spelling of "AccuWeather" and sentence-casing plus grammar (#137696) --- homeassistant/components/accuweather/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 78a49b8b877..d0250a382e9 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -16,7 +16,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "requests_exceeded": "The allowed number of requests to Accuweather API has been exceeded. You have to wait or change API Key." + "requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key." } }, "entity": { From 2eb6a03640012b8b9d318b461a453dc8f0bcf794 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:38:41 +0100 Subject: [PATCH 261/410] Pass in the config_entry in azure_devops coordinator init (#137722) explicitly pass in the config_entry in azure_devops coordinator init --- homeassistant/components/azure_devops/__init__.py | 10 ++-------- homeassistant/components/azure_devops/coordinator.py | 11 +++++++---- homeassistant/components/azure_devops/sensor.py | 3 +-- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 9890d47fbb5..0522e9778df 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -9,9 +9,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_PAT, CONF_PROJECT -from .coordinator import AzureDevOpsDataUpdateCoordinator - -type AzureDevOpsConfigEntry = ConfigEntry[AzureDevOpsDataUpdateCoordinator] +from .coordinator import AzureDevOpsConfigEntry, AzureDevOpsDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -22,11 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AzureDevOpsConfigEntry) """Set up Azure DevOps from a config entry.""" # Create the data update coordinator - coordinator = AzureDevOpsDataUpdateCoordinator( - hass, - _LOGGER, - entry=entry, - ) + coordinator = AzureDevOpsDataUpdateCoordinator(hass, entry, _LOGGER) # Store the coordinator in runtime data entry.runtime_data = coordinator diff --git a/homeassistant/components/azure_devops/coordinator.py b/homeassistant/components/azure_devops/coordinator.py index 21fb76560c3..efbecfbcd19 100644 --- a/homeassistant/components/azure_devops/coordinator.py +++ b/homeassistant/components/azure_devops/coordinator.py @@ -28,6 +28,8 @@ from .data import AzureDevOpsData BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" IGNORED_CATEGORIES: Final[list[Category]] = [Category.COMPLETED, Category.REMOVED] +type AzureDevOpsConfigEntry = ConfigEntry[AzureDevOpsDataUpdateCoordinator] + def ado_exception_none_handler(func: Callable) -> Callable: """Handle exceptions or None to always return a value or raise.""" @@ -50,28 +52,29 @@ class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]): """Class to manage and fetch Azure DevOps data.""" client: DevOpsClient + config_entry: AzureDevOpsConfigEntry organization: str project: Project def __init__( self, hass: HomeAssistant, + config_entry: AzureDevOpsConfigEntry, logger: logging.Logger, - *, - entry: ConfigEntry, ) -> None: """Initialize global Azure DevOps data updater.""" - self.title = entry.title + self.title = config_entry.title super().__init__( hass=hass, logger=logger, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=300), ) self.client = DevOpsClient(session=async_get_clientsession(hass)) - self.organization = entry.data[CONF_ORG] + self.organization = config_entry.data[CONF_ORG] @ado_exception_none_handler async def authorize( diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index fd47115214a..1d590032a85 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -21,8 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import AzureDevOpsConfigEntry -from .coordinator import AzureDevOpsDataUpdateCoordinator +from .coordinator import AzureDevOpsConfigEntry, AzureDevOpsDataUpdateCoordinator from .entity import AzureDevOpsEntity _LOGGER = logging.getLogger(__name__) From 1f0f1ac388047dcc9f71be2f6070e78760b57d8a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:39:21 +0100 Subject: [PATCH 262/410] Explicitly pass in the config_entry in autarco coordinator init (#137718) explicitly pass in the config_entry in autarco coordinator init --- homeassistant/components/autarco/__init__.py | 8 +++----- homeassistant/components/autarco/coordinator.py | 6 +++++- homeassistant/components/autarco/diagnostics.py | 2 +- homeassistant/components/autarco/sensor.py | 3 +-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/autarco/__init__.py b/homeassistant/components/autarco/__init__.py index f42bfdf4a0e..a524535c122 100644 --- a/homeassistant/components/autarco/__init__.py +++ b/homeassistant/components/autarco/__init__.py @@ -6,18 +6,15 @@ import asyncio from autarco import Autarco, AutarcoConnectionError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import AutarcoDataUpdateCoordinator +from .coordinator import AutarcoConfigEntry, AutarcoDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type AutarcoConfigEntry = ConfigEntry[list[AutarcoDataUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: AutarcoConfigEntry) -> bool: """Set up Autarco from a config entry.""" @@ -34,7 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutarcoConfigEntry) -> b raise ConfigEntryNotReady from err coordinators: list[AutarcoDataUpdateCoordinator] = [ - AutarcoDataUpdateCoordinator(hass, client, site) for site in account_sites + AutarcoDataUpdateCoordinator(hass, entry, client, site) + for site in account_sites ] await asyncio.gather( diff --git a/homeassistant/components/autarco/coordinator.py b/homeassistant/components/autarco/coordinator.py index dd8786bca25..deb40155443 100644 --- a/homeassistant/components/autarco/coordinator.py +++ b/homeassistant/components/autarco/coordinator.py @@ -22,6 +22,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type AutarcoConfigEntry = ConfigEntry[list[AutarcoDataUpdateCoordinator]] + class AutarcoData(NamedTuple): """Class for defining data in dict.""" @@ -35,11 +37,12 @@ class AutarcoData(NamedTuple): class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]): """Class to manage fetching Autarco data from the API.""" - config_entry: ConfigEntry + config_entry: AutarcoConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: AutarcoConfigEntry, client: Autarco, account_site: AccountSite, ) -> None: @@ -47,6 +50,7 @@ class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/autarco/diagnostics.py b/homeassistant/components/autarco/diagnostics.py index c865a38ffd8..a2dd0c5a361 100644 --- a/homeassistant/components/autarco/diagnostics.py +++ b/homeassistant/components/autarco/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import AutarcoConfigEntry, AutarcoDataUpdateCoordinator +from .coordinator import AutarcoConfigEntry, AutarcoDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/autarco/sensor.py b/homeassistant/components/autarco/sensor.py index c870197a504..b7c4312815b 100644 --- a/homeassistant/components/autarco/sensor.py +++ b/homeassistant/components/autarco/sensor.py @@ -20,9 +20,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AutarcoConfigEntry from .const import DOMAIN -from .coordinator import AutarcoDataUpdateCoordinator +from .coordinator import AutarcoConfigEntry, AutarcoDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) From 42169e28a48a5dd68d918f761e3b653a7e343e25 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:45:23 +0100 Subject: [PATCH 263/410] Explicitly pass in the config_entry in airnow coordinator init (#137699) explicitly pass in the config_entry in airnow coordinator init --- homeassistant/components/airnow/__init__.py | 6 ++---- homeassistant/components/airnow/coordinator.py | 14 +++++++++++++- homeassistant/components/airnow/diagnostics.py | 2 +- homeassistant/components/airnow/sensor.py | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 2047a9d41bc..6fb7e90502f 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -15,13 +15,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import AirNowDataUpdateCoordinator +from .coordinator import AirNowConfigEntry, AirNowDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type AirNowConfigEntry = ConfigEntry[AirNowDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: """Set up AirNow from a config entry.""" @@ -38,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo # Setup the Coordinator session = async_get_clientsession(hass) coordinator = AirNowDataUpdateCoordinator( - hass, session, api_key, latitude, longitude, distance, update_interval + hass, entry, session, api_key, latitude, longitude, distance, update_interval ) # Sync with Coordinator diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 9434d368dbe..ee5bf4a1dd7 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -10,6 +10,7 @@ from pyairnow import WebServiceAPI from pyairnow.conv import aqi_to_concentration from pyairnow.errors import AirNowError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -34,13 +35,18 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type AirNowConfigEntry = ConfigEntry[AirNowDataUpdateCoordinator] + class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The AirNow update coordinator.""" + config_entry: AirNowConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: AirNowConfigEntry, session: ClientSession, api_key: str, latitude: float, @@ -55,7 +61,13 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.airnow = WebServiceAPI(api_key, session=session) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" diff --git a/homeassistant/components/airnow/diagnostics.py b/homeassistant/components/airnow/diagnostics.py index 76cc35fb13c..bd6dab9dc47 100644 --- a/homeassistant/components/airnow/diagnostics.py +++ b/homeassistant/components/airnow/diagnostics.py @@ -13,7 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import AirNowConfigEntry +from .coordinator import AirNowConfigEntry ATTR_LATITUDE_CAP = "Latitude" ATTR_LONGITUDE_CAP = "Longitude" diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 1abf93514a5..c8b1c985c8b 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AirNowConfigEntry, AirNowDataUpdateCoordinator from .const import ( ATTR_API_AQI, ATTR_API_AQI_DESCRIPTION, @@ -43,6 +42,7 @@ from .const import ( DOMAIN, US_TZ_OFFSETS, ) +from .coordinator import AirNowConfigEntry, AirNowDataUpdateCoordinator ATTRIBUTION = "Data provided by AirNow" From a8de073076d911c2e385196dc897f0ede4e5e296 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:46:21 +0100 Subject: [PATCH 264/410] Explicitly pass in the config_entry in airly coordinator init (#137698) explicitly pass in the config_entry in airly coordinator init --- homeassistant/components/airly/__init__.py | 14 +++++++++----- homeassistant/components/airly/coordinator.py | 14 +++++++++++++- homeassistant/components/airly/diagnostics.py | 2 +- homeassistant/components/airly/sensor.py | 2 +- homeassistant/components/airly/system_health.py | 2 +- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index ad3ee5fca4d..18ad1c8c402 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -6,21 +6,18 @@ from datetime import timedelta import logging from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_USE_NEAREST, DOMAIN, MIN_UPDATE_INTERVAL -from .coordinator import AirlyDataUpdateCoordinator +from .coordinator import AirlyConfigEntry, AirlyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -type AirlyConfigEntry = ConfigEntry[AirlyDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> bool: """Set up Airly as config entry.""" @@ -60,7 +57,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo update_interval = timedelta(minutes=MIN_UPDATE_INTERVAL) coordinator = AirlyDataUpdateCoordinator( - hass, websession, api_key, latitude, longitude, update_interval, use_nearest + hass, + entry, + websession, + api_key, + latitude, + longitude, + update_interval, + use_nearest, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index fa826ba6efc..b255c5f078f 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -10,6 +10,7 @@ from aiohttp.client_exceptions import ClientConnectorError from airly import Airly from airly.exceptions import AirlyError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -27,6 +28,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type AirlyConfigEntry = ConfigEntry[AirlyDataUpdateCoordinator] + def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: """Return data update interval. @@ -58,9 +61,12 @@ def set_update_interval(instances_count: int, requests_remaining: int) -> timede class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): """Define an object to hold Airly data.""" + config_entry: AirlyConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: AirlyConfigEntry, session: ClientSession, api_key: str, latitude: float, @@ -76,7 +82,13 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i self.airly = Airly(api_key, session, language=language) self.use_nearest = use_nearest - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> dict[str, str | float | int]: """Update data via library.""" diff --git a/homeassistant/components/airly/diagnostics.py b/homeassistant/components/airly/diagnostics.py index 8bf75baf1d1..6e9e55a4311 100644 --- a/homeassistant/components/airly/diagnostics.py +++ b/homeassistant/components/airly/diagnostics.py @@ -13,7 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import AirlyConfigEntry +from .coordinator import AirlyConfigEntry TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIQUE_ID} diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 2126b838269..fbf73ed753e 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -24,7 +24,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AirlyConfigEntry, AirlyDataUpdateCoordinator from .const import ( ATTR_ADVICE, ATTR_API_ADVICE, @@ -52,6 +51,7 @@ from .const import ( SUFFIX_PERCENT, URL, ) +from .coordinator import AirlyConfigEntry, AirlyDataUpdateCoordinator PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/airly/system_health.py b/homeassistant/components/airly/system_health.py index 688b6d06189..629cb255122 100644 --- a/homeassistant/components/airly/system_health.py +++ b/homeassistant/components/airly/system_health.py @@ -9,8 +9,8 @@ from airly import Airly from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback -from . import AirlyConfigEntry from .const import DOMAIN +from .coordinator import AirlyConfigEntry @callback From 2b7e67aa0cd5cbbf06e5631fe3463408b420e4ee Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:47:39 +0100 Subject: [PATCH 265/410] Explicitly pass in the config entry in anova coordinator init (#137701) * explicitly pass in the config_entry in anova coordinator init * fix anova --- homeassistant/components/anova/__init__.py | 5 ++-- homeassistant/components/anova/coordinator.py | 25 ++++++++++++++++--- homeassistant/components/anova/models.py | 20 --------------- homeassistant/components/anova/sensor.py | 3 +-- 4 files changed, 25 insertions(+), 28 deletions(-) delete mode 100644 homeassistant/components/anova/models.py diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 4ae4750b9a9..9307cfc4bdd 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -18,8 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .coordinator import AnovaCoordinator -from .models import AnovaConfigEntry, AnovaData +from .coordinator import AnovaConfigEntry, AnovaCoordinator, AnovaData PLATFORMS = [Platform.SENSOR] @@ -59,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> boo # websocket client assert api.websocket_handler is not None devices = list(api.websocket_handler.devices.values()) - coordinators = [AnovaCoordinator(hass, device) for device in devices] + coordinators = [AnovaCoordinator(hass, entry, device) for device in devices] entry.runtime_data = AnovaData(api_jwt=api.jwt, coordinators=coordinators, api=api) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index 93c6fdbf1c5..811c32c97b5 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,8 +1,9 @@ """Support for Anova Coordinators.""" +from dataclasses import dataclass import logging -from anova_wifi import APCUpdate, APCWifiDevice +from anova_wifi import AnovaApi, APCUpdate, APCWifiDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,15 +15,33 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass +class AnovaData: + """Data for the Anova integration.""" + + api_jwt: str + coordinators: list["AnovaCoordinator"] + api: AnovaApi + + +type AnovaConfigEntry = ConfigEntry[AnovaData] + + class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): """Anova custom coordinator.""" - config_entry: ConfigEntry + config_entry: AnovaConfigEntry - def __init__(self, hass: HomeAssistant, anova_device: APCWifiDevice) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: AnovaConfigEntry, + anova_device: APCWifiDevice, + ) -> None: """Set up Anova Coordinator.""" super().__init__( hass, + config_entry=config_entry, name="Anova Precision Cooker", logger=_LOGGER, ) diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py deleted file mode 100644 index eef8180cf88..00000000000 --- a/homeassistant/components/anova/models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Dataclass models for the Anova integration.""" - -from dataclasses import dataclass - -from anova_wifi import AnovaApi - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import AnovaCoordinator - -type AnovaConfigEntry = ConfigEntry[AnovaData] - - -@dataclass -class AnovaData: - """Data for the Anova integration.""" - - api_jwt: str - coordinators: list[AnovaCoordinator] - api: AnovaApi diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index aa572a0ee9b..7365e4597ba 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -18,9 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import AnovaCoordinator +from .coordinator import AnovaConfigEntry, AnovaCoordinator from .entity import AnovaDescriptionEntity -from .models import AnovaConfigEntry @dataclass(frozen=True, kw_only=True) From babb1c55898450faefe1767c34533e5540a0b0d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:51:05 +0100 Subject: [PATCH 266/410] Explicitly pass in the config_entry in bsblan coordinator init (#137725) explicitly pass in the config_entry in bsblan coordinator init --- homeassistant/components/bsblan/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index be9030d95b0..5c5e23efa8a 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -38,6 +38,7 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}", update_interval=self._get_update_interval(), ) From 8f1a0eadc08368dd127be1f3f47e5209d5e9f084 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:52:13 +0100 Subject: [PATCH 267/410] Pass in the config_entry in brother coordinator init (#137726) explicitly pass in the config_entry in brother coordinator init --- homeassistant/components/brother/__init__.py | 7 ++----- homeassistant/components/brother/coordinator.py | 10 +++++++++- homeassistant/components/brother/diagnostics.py | 2 +- homeassistant/components/brother/sensor.py | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index e828d35f9c7..464e6629224 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -5,17 +5,14 @@ from __future__ import annotations from brother import Brother, SnmpError from homeassistant.components.snmp import async_get_snmp_engine -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import BrotherDataUpdateCoordinator +from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type BrotherConfigEntry = ConfigEntry[BrotherDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool: """Set up Brother from a config entry.""" @@ -30,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b except (ConnectionError, SnmpError, TimeoutError) as error: raise ConfigEntryNotReady from error - coordinator = BrotherDataUpdateCoordinator(hass, brother) + coordinator = BrotherDataUpdateCoordinator(hass, entry, brother) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/brother/coordinator.py b/homeassistant/components/brother/coordinator.py index 69463d107e4..4f518ba8a25 100644 --- a/homeassistant/components/brother/coordinator.py +++ b/homeassistant/components/brother/coordinator.py @@ -5,6 +5,7 @@ import logging from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -12,17 +13,24 @@ from .const import DOMAIN, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) +type BrotherConfigEntry = ConfigEntry[BrotherDataUpdateCoordinator] + class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): """Class to manage fetching Brother data from the printer.""" - def __init__(self, hass: HomeAssistant, brother: Brother) -> None: + config_entry: BrotherConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: BrotherConfigEntry, brother: Brother + ) -> None: """Initialize.""" self.brother = brother super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/brother/diagnostics.py b/homeassistant/components/brother/diagnostics.py index d4a6c6c5400..33b2e8297e4 100644 --- a/homeassistant/components/brother/diagnostics.py +++ b/homeassistant/components/brother/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import BrotherConfigEntry +from .coordinator import BrotherConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index d49ebdf07ca..087a971f928 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -24,8 +24,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import BrotherConfigEntry, BrotherDataUpdateCoordinator from .const import DOMAIN +from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator ATTR_COUNTER = "counter" ATTR_REMAINING_PAGES = "remaining_pages" From c85f7d0794f547e8f73bbc6a363d9d0235dca4f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 19:52:50 +0100 Subject: [PATCH 268/410] Explicitly pass in the config_entry in blink coordinator init (#137727) explicitly pass in the config_entry in blink coordinator init --- homeassistant/components/blink/__init__.py | 2 +- homeassistant/components/blink/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index f6516434cd2..d328849e6fe 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -85,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BlinkConfigEntry) -> boo auth_data = deepcopy(dict(entry.data)) blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = BlinkUpdateCoordinator(hass, blink) + coordinator = BlinkUpdateCoordinator(hass, entry, blink) try: await blink.start() diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index 7278dabe083..b9a0db101b4 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -23,12 +23,17 @@ type BlinkConfigEntry = ConfigEntry[BlinkUpdateCoordinator] class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """BlinkUpdateCoordinator - In charge of downloading the data for a site.""" - def __init__(self, hass: HomeAssistant, api: Blink) -> None: + config_entry: BlinkConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: BlinkConfigEntry, api: Blink + ) -> None: """Initialize the data service.""" self.api = api super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL), ) From 684d8dac0dc9f9209ba3d7257a0d40edd89b04ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Feb 2025 13:03:19 -0600 Subject: [PATCH 269/410] Bump SQLAlchemy to 2.0.38 (#137693) --- 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 7cef284ef60..0b8532bedea 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.37", + "SQLAlchemy==2.0.38", "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 0094770d53b..c18b1b9f05f 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.37", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.38", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 05c05d93548..0f53b732c13 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.37 +SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index c6c506dbff7..3936fdb3a1e 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.37", + "SQLAlchemy==2.0.38", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index bd54a380fd3..f0ff3b8054a 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.37 +SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 56e7ddf173a..306e5891c86 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.37 +SQLAlchemy==2.0.38 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f051dde3162..efe7b9d5418 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.37 +SQLAlchemy==2.0.38 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From 1ff9ec661caa92072f801e9ac5e63aff56812daa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:07:01 +0100 Subject: [PATCH 270/410] Explicitly pass in the config_entry in ambient_network coordinator init (#137707) explicitly pass in the config_entry in ambient_network coordinator init --- .../components/ambient_network/__init__.py | 7 ++----- .../components/ambient_network/coordinator.py | 16 +++++++++++++--- .../components/ambient_network/sensor.py | 3 +-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ambient_network/__init__.py b/homeassistant/components/ambient_network/__init__.py index e9443a676b5..5c39982eb91 100644 --- a/homeassistant/components/ambient_network/__init__.py +++ b/homeassistant/components/ambient_network/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from aioambient.open_api import OpenAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import AmbientNetworkDataUpdateCoordinator - -type AmbientNetworkConfigEntry = ConfigEntry[AmbientNetworkDataUpdateCoordinator] +from .coordinator import AmbientNetworkConfigEntry, AmbientNetworkDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -21,7 +18,7 @@ async def async_setup_entry( """Set up the Ambient Weather Network from a config entry.""" api = OpenAPI() - coordinator = AmbientNetworkDataUpdateCoordinator(hass, api) + coordinator = AmbientNetworkDataUpdateCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/ambient_network/coordinator.py b/homeassistant/components/ambient_network/coordinator.py index 2f51c3bc0cb..5fb1939f6b4 100644 --- a/homeassistant/components/ambient_network/coordinator.py +++ b/homeassistant/components/ambient_network/coordinator.py @@ -19,17 +19,27 @@ from .helper import get_station_name SCAN_INTERVAL = timedelta(minutes=5) +type AmbientNetworkConfigEntry = ConfigEntry[AmbientNetworkDataUpdateCoordinator] + class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The Ambient Network Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: AmbientNetworkConfigEntry station_name: str last_measured: datetime | None = None - def __init__(self, hass: HomeAssistant, api: OpenAPI) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: AmbientNetworkConfigEntry, api: OpenAPI + ) -> None: """Initialize the coordinator.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.api = api async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 336745f88ff..9d262e5a987 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -28,8 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import AmbientNetworkConfigEntry -from .coordinator import AmbientNetworkDataUpdateCoordinator +from .coordinator import AmbientNetworkConfigEntry, AmbientNetworkDataUpdateCoordinator from .entity import AmbientNetworkEntity TYPE_AQI_PM25 = "aqi_pm25" From c814f4f307f33974b82939d4e1fb1a384f3ef0d9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:11:04 +0100 Subject: [PATCH 271/410] Explicitly pass in the config entry in amberelectric coordinator init (#137700) * explicitly pass in the config_entry in amberelectric coordinator init * fix amberelectric tests --- .../components/amberelectric/__init__.py | 7 +--- .../components/amberelectric/binary_sensor.py | 3 +- .../components/amberelectric/coordinator.py | 12 +++++- .../components/amberelectric/sensor.py | 3 +- .../amberelectric/test_coordinator.py | 37 +++++++++++++++---- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 29d8f166f2a..9eab6f42ad3 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -2,14 +2,11 @@ import amberelectric -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from .const import CONF_SITE_ID, PLATFORMS -from .coordinator import AmberUpdateCoordinator - -type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: @@ -19,7 +16,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> boo api_instance = amberelectric.AmberApi(api_client) site_id = entry.data[CONF_SITE_ID] - coordinator = AmberUpdateCoordinator(hass, api_instance, site_id) + coordinator = AmberUpdateCoordinator(hass, entry, api_instance, site_id) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index a9fa00d0129..66292ea5524 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -12,9 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AmberConfigEntry from .const import ATTRIBUTION -from .coordinator import AmberUpdateCoordinator +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator PRICE_SPIKE_ICONS = { "none": "mdi:power-plug", diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 57028e07d21..1edf64ba0d6 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -13,11 +13,14 @@ from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.rest import ApiException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] + def is_current(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is a CurrentInterval.""" @@ -70,13 +73,20 @@ def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" + config_entry: AmberConfigEntry + def __init__( - self, hass: HomeAssistant, api: amberelectric.AmberApi, site_id: str + self, + hass: HomeAssistant, + config_entry: AmberConfigEntry, + api: amberelectric.AmberApi, + site_id: str, ) -> None: """Initialise the data service.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="amberelectric", update_interval=timedelta(minutes=1), ) diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index cdf40e5804d..49d6e5f4eac 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -22,9 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AmberConfigEntry from .const import ATTRIBUTION -from .coordinator import AmberUpdateCoordinator, normalize_descriptor +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 0a8f5b874fa..6faabc924b4 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -16,10 +16,12 @@ from amberelectric.models.spike_status import SpikeStatus from dateutil import parser import pytest +from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME from homeassistant.components.amberelectric.coordinator import ( AmberUpdateCoordinator, normalize_descriptor, ) +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -33,6 +35,17 @@ from .helpers import ( generate_current_interval, ) +from tests.common import MockConfigEntry + +MOCKED_ENTRY = MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: "psk_0000000000000000", + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, +) + @pytest.fixture(name="current_price_api") def mock_api_current_price() -> Generator: @@ -101,7 +114,9 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) """Test fetching a site with only a general channel.""" current_price_api.get_current_prices.return_value = GENERAL_CHANNEL - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( @@ -130,7 +145,9 @@ async def test_fetch_no_general_site( """Test fetching a site with no general channel.""" current_price_api.get_current_prices.return_value = CONTROLLED_LOAD_CHANNEL - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) with pytest.raises(UpdateFailed): await data_service._async_update_data() @@ -143,7 +160,9 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> """Test that the old values are maintained if a second call fails.""" current_price_api.get_current_prices.return_value = GENERAL_CHANNEL - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( @@ -193,7 +212,7 @@ async def test_fetch_general_and_controlled_load_site( GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL ) data_service = AmberUpdateCoordinator( - hass, current_price_api, GENERAL_AND_CONTROLLED_SITE_ID + hass, MOCKED_ENTRY, current_price_api, GENERAL_AND_CONTROLLED_SITE_ID ) result = await data_service._async_update_data() @@ -233,7 +252,7 @@ async def test_fetch_general_and_feed_in_site( GENERAL_CHANNEL + FEED_IN_CHANNEL ) data_service = AmberUpdateCoordinator( - hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID + hass, MOCKED_ENTRY, current_price_api, GENERAL_AND_FEED_IN_SITE_ID ) result = await data_service._async_update_data() @@ -273,7 +292,9 @@ async def test_fetch_potential_spike( ] general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL current_price_api.get_current_prices.return_value = general_channel - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() assert result["grid"]["price_spike"] == "potential" @@ -288,6 +309,8 @@ async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None ] general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE current_price_api.get_current_prices.return_value = general_channel - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() assert result["grid"]["price_spike"] == "spike" From 54be256beaae4646a04ec2d2af169f3395176df7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Feb 2025 20:15:04 +0100 Subject: [PATCH 272/410] Fix wrong reference for description of password field in bring (#137720) * Fix wrong reference for description of password field in bring * Fix second occurrence as well --- homeassistant/components/bring/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index f8c261db3fd..1dbe0adbf6c 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -25,7 +25,7 @@ }, "data_description": { "email": "[%key:component::bring::config::step::user::data_description::email%]", - "password": "[%key:component::bring::config::step::user::data_description::email%]" + "password": "[%key:component::bring::config::step::user::data_description::password%]" } }, "reconfigure": { @@ -37,7 +37,7 @@ }, "data_description": { "email": "[%key:component::bring::config::step::user::data_description::email%]", - "password": "[%key:component::bring::config::step::user::data_description::email%]" + "password": "[%key:component::bring::config::step::user::data_description::password%]" } } }, From 3c7b9bec3c1ed4960fd4c819918462635c6dbe0c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:23:42 +0100 Subject: [PATCH 273/410] Explicitly pass in the config_entry in discovergy coordinator (#137734) explicitly pass in the config_entry in coordinator --- homeassistant/components/discovergy/__init__.py | 6 ++---- homeassistant/components/discovergy/coordinator.py | 7 +++++++ homeassistant/components/discovergy/diagnostics.py | 2 +- homeassistant/components/discovergy/sensor.py | 3 +-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 81c33adc052..9cf63176de6 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -6,18 +6,15 @@ from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client -from .coordinator import DiscovergyUpdateCoordinator +from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> bool: """Set up Discovergy from a config entry.""" @@ -46,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - # so we have data when entities are added coordinator = DiscovergyUpdateCoordinator( hass=hass, + config_entry=entry, meter=meter, discovergy_client=client, ) diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 3be4c71c987..d4ef87049b8 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -9,19 +9,25 @@ from pydiscovergy import Discovergy from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin from pydiscovergy.models import Meter, Reading +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) +type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] + class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """The Discovergy update coordinator.""" + config_entry: DiscovergyConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: DiscovergyConfigEntry, meter: Meter, discovergy_client: Discovergy, ) -> None: @@ -32,6 +38,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Discovergy meter {meter.meter_id}", update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index 3857404db81..f4d6a3397d0 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import DiscovergyConfigEntry +from .coordinator import DiscovergyConfigEntry TO_REDACT_METER = { "serial_number", diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index a3ec132db9b..65b1722e0d8 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -24,9 +24,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DiscovergyConfigEntry from .const import DOMAIN, MANUFACTURER -from .coordinator import DiscovergyUpdateCoordinator +from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator PARALLEL_UPDATES = 0 From 55cda68866a10853cfdae0939269eabb68330edf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:34:52 +0100 Subject: [PATCH 274/410] Limit flume ConfigEntrySelect to integration domain (#137661) --- homeassistant/components/flume/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 9da0e5163c5..24ed41b21c1 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -35,7 +35,7 @@ SERVICE_LIST_NOTIFICATIONS = "list_notifications" CONF_CONFIG_ENTRY = "config_entry" LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( { - vol.Required(CONF_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(CONF_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), }, ) From c300be5eeec0a7f4557b319aae9ea49f1250d710 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:41:40 +0100 Subject: [PATCH 275/410] Explicitly pass in the config_entry in aussie_broadband coordinator init (#137719) explicitly pass in the config_entry in aussie_broadband coordinator init --- homeassistant/components/aussie_broadband/__init__.py | 2 +- .../components/aussie_broadband/coordinator.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 52b48b1d0d6..673df594e89 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry( # Initiate a Data Update Coordinator for each service for service in services: service["coordinator"] = AussieBroadbandDataUpdateCoordinator( - hass, client, service["service_id"] + hass, entry, client, service["service_id"] ) await service["coordinator"].async_config_entry_first_refresh() diff --git a/homeassistant/components/aussie_broadband/coordinator.py b/homeassistant/components/aussie_broadband/coordinator.py index 844442985c0..20987c5f30f 100644 --- a/homeassistant/components/aussie_broadband/coordinator.py +++ b/homeassistant/components/aussie_broadband/coordinator.py @@ -34,11 +34,20 @@ type AussieBroadbandConfigEntry = ConfigEntry[list[AussieBroadbandServiceData]] class AussieBroadbandDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Aussie Broadand data update coordinator.""" - def __init__(self, hass: HomeAssistant, client: AussieBB, service_id: str) -> None: + config_entry: AussieBroadbandConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AussieBroadbandConfigEntry, + client: AussieBB, + service_id: str, + ) -> None: """Initialize Atag coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Aussie Broadband {service_id}", update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL), ) From c595b12e9829b3d91361dfaa636092c467aa59a2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:41:53 +0100 Subject: [PATCH 276/410] Explicitly pass in the config_entry in airzone coordinator init (#137702) explicitly pass in the config_entry in airzone coordinator init --- homeassistant/components/airzone/__init__.py | 7 ++----- homeassistant/components/airzone/binary_sensor.py | 3 +-- homeassistant/components/airzone/climate.py | 3 +-- homeassistant/components/airzone/coordinator.py | 13 ++++++++++++- homeassistant/components/airzone/diagnostics.py | 2 +- homeassistant/components/airzone/entity.py | 3 +-- homeassistant/components/airzone/select.py | 3 +-- homeassistant/components/airzone/sensor.py | 3 +-- homeassistant/components/airzone/switch.py | 3 +-- homeassistant/components/airzone/water_heater.py | 3 +-- 10 files changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index aa168dce858..a56f2cc2445 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -15,7 +15,6 @@ from aioairzone.const import ( ) from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -25,7 +24,7 @@ from homeassistant.helpers import ( ) from .const import DOMAIN, MANUFACTURER -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -38,8 +37,6 @@ PLATFORMS: list[Platform] = [ _LOGGER = logging.getLogger(__name__) -type AirzoneConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] - async def _async_migrate_unique_ids( hass: HomeAssistant, @@ -90,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b ) airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options) - coordinator = AirzoneUpdateCoordinator(hass, airzone) + coordinator = AirzoneUpdateCoordinator(hass, entry, airzone) await coordinator.async_config_entry_first_refresh() await _async_migrate_unique_ids(hass, entry, coordinator) diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index eec78156fe0..48f6ce8fd94 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -25,8 +25,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 4ed54286cff..23355a070ab 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -50,9 +50,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneConfigEntry from .const import API_TEMPERATURE_STEP, TEMP_UNIT_LIB_TO_HASS -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity BASE_FAN_SPEEDS: Final[dict[int, str]] = { diff --git a/homeassistant/components/airzone/coordinator.py b/homeassistant/components/airzone/coordinator.py index 8ec2cbe07ca..4b4519beed8 100644 --- a/homeassistant/components/airzone/coordinator.py +++ b/homeassistant/components/airzone/coordinator.py @@ -10,6 +10,7 @@ from typing import Any from aioairzone.exceptions import AirzoneError from aioairzone.localapi import AirzoneLocalApi +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,17 +20,27 @@ SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +type AirzoneConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] + class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Airzone device.""" - def __init__(self, hass: HomeAssistant, airzone: AirzoneLocalApi) -> None: + config_entry: AirzoneConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirzoneConfigEntry, + airzone: AirzoneLocalApi, + ) -> None: """Initialize.""" self.airzone = airzone super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/airzone/diagnostics.py b/homeassistant/components/airzone/diagnostics.py index 2945df7b6fb..e745a85ee5e 100644 --- a/homeassistant/components/airzone/diagnostics.py +++ b/homeassistant/components/airzone/diagnostics.py @@ -10,7 +10,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from . import AirzoneConfigEntry +from .coordinator import AirzoneConfigEntry TO_REDACT_API = [ API_MAC, diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 59d58fb62b0..c0d7901981b 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -31,9 +31,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AirzoneConfigEntry from .const import DOMAIN, MANUFACTURER -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 2bc11bc4228..56a9b06ea21 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -27,8 +27,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index ef8ddbb3b65..0b5c5666c89 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -30,9 +30,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneConfigEntry from .const import TEMP_UNIT_LIB_TO_HASS -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneEntity, AirzoneHotWaterEntity, diff --git a/homeassistant/components/airzone/switch.py b/homeassistant/components/airzone/switch.py index 93136810604..69bf33666a5 100644 --- a/homeassistant/components/airzone/switch.py +++ b/homeassistant/components/airzone/switch.py @@ -16,8 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index 8fd563b33d8..2a1ca72db21 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -30,9 +30,8 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneConfigEntry from .const import TEMP_UNIT_LIB_TO_HASS -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneHotWaterEntity OPERATION_LIB_TO_HASS: Final[dict[HotWaterOperation, str]] = { From 7007bd121f8ec9f8d3bf04d0b69e0a326d1c9883 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:42:05 +0100 Subject: [PATCH 277/410] Explicitly pass in the config_entry in aquacell coordinator init (#137713) explicitly pass in the config_entry in aquacell coordinator init --- homeassistant/components/aquacell/__init__.py | 9 +++------ homeassistant/components/aquacell/coordinator.py | 12 ++++++++++-- homeassistant/components/aquacell/sensor.py | 3 +-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/aquacell/__init__.py b/homeassistant/components/aquacell/__init__.py index e44c0f00fa8..60787840fcb 100644 --- a/homeassistant/components/aquacell/__init__.py +++ b/homeassistant/components/aquacell/__init__.py @@ -5,18 +5,15 @@ from __future__ import annotations from aioaquacell import AquacellApi from aioaquacell.const import Brand -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BRAND -from .coordinator import AquacellCoordinator +from .coordinator import AquacellConfigEntry, AquacellCoordinator PLATFORMS = [Platform.SENSOR] -type AquacellConfigEntry = ConfigEntry[AquacellCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> bool: """Set up Aquacell from a config entry.""" @@ -26,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> aquacell_api = AquacellApi(session, brand) - coordinator = AquacellCoordinator(hass, aquacell_api) + coordinator = AquacellCoordinator(hass, entry, aquacell_api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -36,6 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aquacell/coordinator.py b/homeassistant/components/aquacell/coordinator.py index ee4afb451b9..135ab0ecb50 100644 --- a/homeassistant/components/aquacell/coordinator.py +++ b/homeassistant/components/aquacell/coordinator.py @@ -26,17 +26,25 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type AquacellConfigEntry = ConfigEntry[AquacellCoordinator] + class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): """My aquacell coordinator.""" - config_entry: ConfigEntry + config_entry: AquacellConfigEntry - def __init__(self, hass: HomeAssistant, aquacell_api: AquacellApi) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: AquacellConfigEntry, + aquacell_api: AquacellApi, + ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Aquacell Coordinator", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py index 702d75a0215..a76d26244ad 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -18,8 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import AquacellConfigEntry -from .coordinator import AquacellCoordinator +from .coordinator import AquacellConfigEntry, AquacellCoordinator from .entity import AquacellEntity PARALLEL_UPDATES = 1 From b9a3cab4b438fc9591fa4c617ed3c057aaa435c9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 20:43:54 +0100 Subject: [PATCH 278/410] Explicitly pass in the config_entry in braviatv coordinator init (#137724) explicitly pass in the config_entry in braviatv coordinator init --- homeassistant/components/braviatv/__init__.py | 7 ++----- homeassistant/components/braviatv/button.py | 3 +-- .../components/braviatv/coordinator.py | 17 +++++++++++------ .../components/braviatv/diagnostics.py | 2 +- homeassistant/components/braviatv/entity.py | 2 +- .../components/braviatv/media_player.py | 2 +- homeassistant/components/braviatv/remote.py | 2 +- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 6593afb75d1..52fcf82b38b 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -7,14 +7,11 @@ from typing import Final from aiohttp import CookieJar from pybravia import BraviaClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .coordinator import BraviaTVCoordinator - -BraviaTVConfigEntry = ConfigEntry[BraviaTVCoordinator] +from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator PLATFORMS: Final[list[Platform]] = [ Platform.BUTTON, @@ -36,8 +33,8 @@ async def async_setup_entry( client = BraviaClient(host, mac, session=session) coordinator = BraviaTVCoordinator( hass=hass, + config_entry=config_entry, client=client, - config=config_entry.data, ) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 358255bd85b..626e5a225b7 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -14,8 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BraviaTVConfigEntry -from .coordinator import BraviaTVCoordinator +from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator from .entity import BraviaTVEntity diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index e08e88073f3..1cc306bd5cf 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -6,7 +6,6 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from datetime import datetime, timedelta from functools import wraps import logging -from types import MappingProxyType from typing import Any, Concatenate, Final from pybravia import ( @@ -20,6 +19,7 @@ from pybravia import ( ) from homeassistant.components.media_player import MediaType +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -39,6 +39,8 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=10) +type BraviaTVConfigEntry = ConfigEntry["BraviaTVCoordinator"] + def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P]( func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]], @@ -64,19 +66,21 @@ def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P]( class BraviaTVCoordinator(DataUpdateCoordinator[None]): """Representation of a Bravia TV Coordinator.""" + config_entry: BraviaTVConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: BraviaTVConfigEntry, client: BraviaClient, - config: MappingProxyType[str, Any], ) -> None: """Initialize Bravia TV Client.""" self.client = client - self.pin = config[CONF_PIN] - self.use_psk = config.get(CONF_USE_PSK, False) - self.client_id = config.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID) - self.nickname = config.get(CONF_NICKNAME, NICKNAME_PREFIX) + self.pin = config_entry.data[CONF_PIN] + self.use_psk = config_entry.data.get(CONF_USE_PSK, False) + self.client_id = config_entry.data.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID) + self.nickname = config_entry.data.get(CONF_NICKNAME, NICKNAME_PREFIX) self.source: str | None = None self.source_list: list[str] = [] self.source_map: dict[str, dict] = {} @@ -98,6 +102,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, request_refresh_debouncer=Debouncer( diff --git a/homeassistant/components/braviatv/diagnostics.py b/homeassistant/components/braviatv/diagnostics.py index 0969674d5c9..b858fd41c09 100644 --- a/homeassistant/components/braviatv/diagnostics.py +++ b/homeassistant/components/braviatv/diagnostics.py @@ -6,7 +6,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant -from . import BraviaTVConfigEntry +from .coordinator import BraviaTVConfigEntry TO_REDACT = {CONF_MAC, CONF_PIN, "macAddr"} diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py index 75540b316a7..b4e370f20d2 100644 --- a/homeassistant/components/braviatv/entity.py +++ b/homeassistant/components/braviatv/entity.py @@ -3,8 +3,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import BraviaTVCoordinator from .const import ATTR_MANUFACTURER, DOMAIN +from .coordinator import BraviaTVCoordinator class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]): diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 4de167a6def..ca48c6ee639 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -18,8 +18,8 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BraviaTVConfigEntry from .const import SourceType +from .coordinator import BraviaTVConfigEntry from .entity import BraviaTVEntity diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 9344d6ec455..9f4a573827b 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -9,7 +9,7 @@ from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BraviaTVConfigEntry +from .coordinator import BraviaTVConfigEntry from .entity import BraviaTVEntity From 292409f1d5d59d9fce5269953fb9654e8982bcb6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 22:16:09 +0100 Subject: [PATCH 279/410] Explicitly pass in the config_entry in aurora_abb_powerone coordinator init (#137715) --- .../components/aurora_abb_powerone/__init__.py | 2 +- .../aurora_abb_powerone/coordinator.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 749d40aeb5c..91166d5c8f5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AuroraAbbConfigEntry) -> comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] - coordinator = AuroraAbbDataUpdateCoordinator(hass, comport, address) + coordinator = AuroraAbbDataUpdateCoordinator(hass, entry, comport, address) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/aurora_abb_powerone/coordinator.py b/homeassistant/components/aurora_abb_powerone/coordinator.py index c3d05da95f3..d38f0716b44 100644 --- a/homeassistant/components/aurora_abb_powerone/coordinator.py +++ b/homeassistant/components/aurora_abb_powerone/coordinator.py @@ -21,12 +21,26 @@ type AuroraAbbConfigEntry = ConfigEntry[AuroraAbbDataUpdateCoordinator] class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): """Class to manage fetching AuroraAbbPowerone data.""" - def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: + config_entry: AuroraAbbConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AuroraAbbConfigEntry, + comport: str, + address: int, + ) -> None: """Initialize the data update coordinator.""" self.available_prev = False self.available = False self.client = AuroraSerialClient(address, comport, parity="N", timeout=1) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) def _update_data(self) -> dict[str, float]: """Fetch new state data for the sensors. From 2d9db62828b16bde716327e980b7db6846919682 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:20:08 +0100 Subject: [PATCH 280/410] Explicitly pass in the config_entry in arve coordinator init (#137712) explicitly pass in the config_entry in arve coordinator init --- homeassistant/components/arve/__init__.py | 2 +- homeassistant/components/arve/coordinator.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/arve/__init__.py b/homeassistant/components/arve/__init__.py index a1b4aa7042e..c5900967bde 100644 --- a/homeassistant/components/arve/__init__.py +++ b/homeassistant/components/arve/__init__.py @@ -13,7 +13,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool: """Set up Arve from a config entry.""" - coordinator = ArveCoordinator(hass) + coordinator = ArveCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/arve/coordinator.py b/homeassistant/components/arve/coordinator.py index f02220e28e2..4b08efd639e 100644 --- a/homeassistant/components/arve/coordinator.py +++ b/homeassistant/components/arve/coordinator.py @@ -30,11 +30,12 @@ class ArveCoordinator(DataUpdateCoordinator[ArveSensProData]): config_entry: ArveConfigEntry devices: ArveDevices - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ArveConfigEntry) -> None: """Initialize Arve coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) From ee80966a1004b9e8b6e4462f90b33bff0ce3f2b6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:30:57 +0100 Subject: [PATCH 281/410] =?UTF-8?q?Explicitly=20pass=20in=20the=20config?= =?UTF-8?q?=5Fentry=20in=20android=5Fip=5Fwebcam=20coordinator=20=E2=80=A6?= =?UTF-8?q?=20(#137705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit explicitly pass in the config_entry in android_ip_webcam coordinator init --- homeassistant/components/android_ip_webcam/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/android_ip_webcam/coordinator.py b/homeassistant/components/android_ip_webcam/coordinator.py index fd6e1fcc4b9..c72d6ae1177 100644 --- a/homeassistant/components/android_ip_webcam/coordinator.py +++ b/homeassistant/components/android_ip_webcam/coordinator.py @@ -35,6 +35,7 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( self.hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN} {config_entry.data[CONF_HOST]}", update_interval=timedelta(seconds=10), ) From 1d3cef5c6f3d4f356cb463c335d296f6b5f6bc56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:38:52 +0100 Subject: [PATCH 282/410] Explicitly pass in the config_entry in analytics_insight coordinator init (#137706) explicitly pass in the config_entry in analytics_insight coordinator init --- homeassistant/components/analytics_insights/__init__.py | 2 +- homeassistant/components/analytics_insights/coordinator.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 69ad98db9df..ee7f6611c65 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry( continue names[integration] = integrations[integration].title - coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, client) + coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py index 701f1a8dbd4..fefd43ed8df 100644 --- a/homeassistant/components/analytics_insights/coordinator.py +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -46,12 +46,16 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic config_entry: AnalyticsInsightsConfigEntry def __init__( - self, hass: HomeAssistant, client: HomeassistantAnalyticsClient + self, + hass: HomeAssistant, + config_entry: AnalyticsInsightsConfigEntry, + client: HomeassistantAnalyticsClient, ) -> None: """Initialize the Homeassistant Analytics data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(hours=12), ) From fd1213b70d174eccacd00a5255aec74d5639c505 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:55:41 +0100 Subject: [PATCH 283/410] Explicitly pass in the config_entry in apcupsd coordinator init (#137709) explicitly pass in the config_entry in apcupsd coordinator init --- homeassistant/components/apcupsd/__init__.py | 7 ++----- homeassistant/components/apcupsd/binary_sensor.py | 3 +-- homeassistant/components/apcupsd/coordinator.py | 13 +++++++++++-- homeassistant/components/apcupsd/diagnostics.py | 2 +- homeassistant/components/apcupsd/sensor.py | 3 +-- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 44edc5c151f..e444f1cd735 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .coordinator import APCUPSdCoordinator - -type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator] +from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR) @@ -20,7 +17,7 @@ async def async_setup_entry( ) -> bool: """Use config values to set up a function enabling status retrieval.""" host, port = config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] - coordinator = APCUPSdCoordinator(hass, host, port) + coordinator = APCUPSdCoordinator(hass, config_entry, host, port) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index cd9e60f7ae4..2a44845618e 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -12,8 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import APCUPSdConfigEntry -from .coordinator import APCUPSdCoordinator +from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 1ae12d8c4b0..e2c1af50cee 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -25,6 +25,8 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = timedelta(seconds=60) REQUEST_REFRESH_COOLDOWN: Final = 5 +type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator] + class APCUPSdData(dict[str, str]): """Store data about an APCUPSd and provide a few helper methods for easier accesses.""" @@ -57,13 +59,20 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): updates from the server. """ - config_entry: ConfigEntry + config_entry: APCUPSdConfigEntry - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: APCUPSdConfigEntry, + host: str, + port: int, + ) -> None: """Initialize the data object.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=UPDATE_INTERVAL, request_refresh_debouncer=Debouncer( diff --git a/homeassistant/components/apcupsd/diagnostics.py b/homeassistant/components/apcupsd/diagnostics.py index fa0908f3144..a4bbf2191d2 100644 --- a/homeassistant/components/apcupsd/diagnostics.py +++ b/homeassistant/components/apcupsd/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import APCUPSdConfigEntry +from .coordinator import APCUPSdConfigEntry TO_REDACT = {"SERIALNO", "HOSTNAME"} diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 9e0abcb1dd9..b3c396daf5e 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -24,9 +24,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import APCUPSdConfigEntry from .const import LAST_S_TEST -from .coordinator import APCUPSdCoordinator +from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator PARALLEL_UPDATES = 0 From 07fdec76e11a82675b924b37e602420a68608a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 7 Feb 2025 23:56:48 +0100 Subject: [PATCH 284/410] Explicitly pass in the config_entry in letpot coordinator (#137759) --- homeassistant/components/letpot/__init__.py | 7 ++----- homeassistant/components/letpot/coordinator.py | 14 +++++++++----- homeassistant/components/letpot/switch.py | 3 +-- homeassistant/components/letpot/time.py | 3 +-- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 5bfc4edfc0c..bc84c22d4a2 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -9,7 +9,6 @@ from letpot.converters import CONVERTERS from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -21,12 +20,10 @@ from .const import ( CONF_REFRESH_TOKEN_EXPIRES, CONF_USER_ID, ) -from .coordinator import LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME] -type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: """Set up LetPot from a config entry.""" @@ -67,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo raise ConfigEntryNotReady from exc coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, auth, device) + LetPotDeviceCoordinator(hass, entry, auth, device) for device in devices if any(converter.supports_type(device.device_type) for converter in CONVERTERS) ] diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index a2a35d566c6..bd787157482 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -4,23 +4,22 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import REQUEST_UPDATE_TIMEOUT -if TYPE_CHECKING: - from . import LetPotConfigEntry - _LOGGER = logging.getLogger(__name__) +type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] + class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Class to handle data updates for a specific garden.""" @@ -31,12 +30,17 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): device_client: LetPotDeviceClient def __init__( - self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice + self, + hass: HomeAssistant, + config_entry: LetPotConfigEntry, + info: AuthenticationInfo, + device: LetPotDevice, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"LetPot {device.serial_number}", ) self._info = info diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 36d07276c48..ab02f2860c6 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -12,8 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LetPotConfigEntry -from .coordinator import LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index 80ce9743d8c..cca088c8e61 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -13,8 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LetPotConfigEntry -from .coordinator import LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache From 0cbef18b73b503b3e30e52e88d934d23b86a32fe Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:01:14 +0100 Subject: [PATCH 285/410] Explicitly pass in the config_entry in eheimdigital coordinator (#137738) --- homeassistant/components/eheimdigital/__init__.py | 7 ++----- homeassistant/components/eheimdigital/climate.py | 3 +-- .../components/eheimdigital/coordinator.py | 14 +++++++++++--- homeassistant/components/eheimdigital/light.py | 3 +-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index a555a87cfbc..26e6bea4d4a 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -2,25 +2,22 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN -from .coordinator import EheimDigitalUpdateCoordinator +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] -type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: EheimDigitalConfigEntry ) -> bool: """Set up EHEIM Digital from a config entry.""" - coordinator = EheimDigitalUpdateCoordinator(hass) + coordinator = EheimDigitalUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 7ad06659089..f0038982965 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -23,9 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EheimDigitalConfigEntry from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE -from .coordinator import EheimDigitalUpdateCoordinator +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator from .entity import EheimDigitalEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index ee4f09426b7..4359a314494 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -22,18 +22,26 @@ type AsyncSetupDeviceEntitiesCallback = Callable[ [str | dict[str, EheimDigitalDevice]], None ] +type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] + class EheimDigitalUpdateCoordinator( DataUpdateCoordinator[dict[str, EheimDigitalDevice]] ): """The EHEIM Digital data update coordinator.""" - config_entry: ConfigEntry + config_entry: EheimDigitalConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: EheimDigitalConfigEntry + ) -> None: """Initialize the EHEIM Digital data update coordinator.""" super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self.hub = EheimDigitalHub( host=self.config_entry.data[CONF_HOST], diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index 5ae0a6e866a..25498cf3af1 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -19,9 +19,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness -from . import EheimDigitalConfigEntry from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE -from .coordinator import EheimDigitalUpdateCoordinator +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator from .entity import EheimDigitalEntity BRIGHTNESS_SCALE = (1, 100) From 7fc92e4c25cec0f4e730137de426134817645f2d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:02:49 +0100 Subject: [PATCH 286/410] Explicitly pass in the config_entry in dremel_3d_printer coordinator (#137740) --- homeassistant/components/dremel_3d_printer/__init__.py | 2 +- homeassistant/components/dremel_3d_printer/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py index 632c42d9b54..33a8ad0e67f 100644 --- a/homeassistant/components/dremel_3d_printer/__init__.py +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry( f"Unable to connect to Dremel 3D Printer: {ex}" ) from ex - coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) + coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator platforms = list(PLATFORMS) diff --git a/homeassistant/components/dremel_3d_printer/coordinator.py b/homeassistant/components/dremel_3d_printer/coordinator.py index 3323569c05f..f2c1876fe0a 100644 --- a/homeassistant/components/dremel_3d_printer/coordinator.py +++ b/homeassistant/components/dremel_3d_printer/coordinator.py @@ -18,11 +18,14 @@ class Dremel3DPrinterDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: DremelConfigEntry - def __init__(self, hass: HomeAssistant, api: Dremel3DPrinter) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: DremelConfigEntry, api: Dremel3DPrinter + ) -> None: """Initialize Dremel 3D Printer data update coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) From e9bfb6baeecfc910239e35c8cfcb4de9b9da0f6b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:04:05 +0100 Subject: [PATCH 287/410] Explicitly pass in the config_entry in emoncms coordinator (#137743) --- homeassistant/components/emoncms/__init__.py | 11 ++++------- homeassistant/components/emoncms/coordinator.py | 7 +++++++ homeassistant/components/emoncms/sensor.py | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 581948bbc6f..012abcc8c9a 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -2,7 +2,6 @@ from pyemoncms import EmoncmsClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -10,12 +9,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER -from .coordinator import EmoncmsCoordinator +from .coordinator import EmonCMSConfigEntry, EmoncmsCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] - def _migrate_unique_id( hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str @@ -68,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b session=async_get_clientsession(hass), ) await _check_unique_id_migration(hass, entry, emoncms_client) - coordinator = EmoncmsCoordinator(hass, emoncms_client) + coordinator = EmoncmsCoordinator(hass, entry, emoncms_client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -77,11 +74,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def update_listener(hass: HomeAssistant, entry: EmonCMSConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/coordinator.py b/homeassistant/components/emoncms/coordinator.py index c6fda5ed7c8..ec439b400d5 100644 --- a/homeassistant/components/emoncms/coordinator.py +++ b/homeassistant/components/emoncms/coordinator.py @@ -5,24 +5,31 @@ from typing import Any from pyemoncms import EmoncmsClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_MESSAGE, CONF_SUCCESS, LOGGER +type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] + class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]): """Emoncms Data Update Coordinator.""" + config_entry: EmonCMSConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient, ) -> None: """Initialize the emoncms data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="emoncms_coordinator", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 1920e06a8e8..e3483d3f5d7 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -53,7 +53,7 @@ from .const import ( FEED_NAME, FEED_TAG, ) -from .coordinator import EmoncmsCoordinator +from .coordinator import EmonCMSConfigEntry, EmoncmsCoordinator SENSORS: dict[str | None, SensorEntityDescription] = { "kWh": SensorEntityDescription( @@ -288,7 +288,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EmonCMSConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the emoncms sensors.""" From 61d1b34cefda6f6e1f23f583ad31f888d414511a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:04:45 +0100 Subject: [PATCH 288/410] Explicitly pass in the config_entry in dwd weather warnings coordinator (#137737) --- .../components/dwd_weather_warnings/__init__.py | 2 +- .../components/dwd_weather_warnings/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 7a56299a35b..727fcf95339 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -16,7 +16,7 @@ async def async_setup_entry( device_registry = dr.async_get(hass) if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): device_registry.async_clear_config_entry(entry.entry_id) - coordinator = DwdWeatherWarningsCoordinator(hass) + coordinator = DwdWeatherWarningsCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index be61304bc06..61656a82de6 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -28,10 +28,16 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): config_entry: DwdWeatherWarningsConfigEntry api: DwdWeatherWarningsAPI - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: DwdWeatherWarningsConfigEntry + ) -> None: """Initialize the dwd_weather_warnings coordinator.""" super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self._device_tracker = None From 7883106e7ce9632a4ae962692cbff15b20fe0472 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Feb 2025 21:25:21 -0500 Subject: [PATCH 289/410] Make sure we always have agent_id in ConversationInput (#137679) * Make sure we always have agent_id in ConversationInput * fix type --- homeassistant/components/conversation/agent_manager.py | 3 +++ homeassistant/components/conversation/default_agent.py | 2 +- homeassistant/components/conversation/http.py | 2 +- homeassistant/components/conversation/models.py | 2 +- .../google_generative_ai_conversation/conversation.py | 2 -- .../components/openai_conversation/conversation.py | 1 - tests/components/conversation/test_init.py | 3 +++ tests/components/conversation/test_trigger.py | 10 +++++----- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index ce3a0cf028d..5ff47977d88 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -79,6 +79,9 @@ async def async_converse( extra_system_prompt: str | None = None, ) -> ConversationResult: """Process text and get intent.""" + if agent_id is None: + agent_id = HOME_ASSISTANT_AGENT + agent = async_get_agent(hass, agent_id) if agent is None: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bd7450e5a0f..23c201d7579 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -381,7 +381,7 @@ class DefaultAgent(ConversationEntity): speech: str = response.speech.get("plain", {}).get("speech", "") chat_log.async_add_assistant_content_without_tools( AssistantContent( - agent_id=user_input.agent_id, # type: ignore[arg-type] + agent_id=user_input.agent_id, content=speech, ) ) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 8134ecb0eee..4d8526a4fd4 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -195,7 +195,7 @@ async def websocket_hass_agent_debug( conversation_id=None, device_id=msg.get("device_id"), language=msg.get("language", hass.config.language), - agent_id=None, + agent_id=agent.entity_id, ) result_dict: dict[str, Any] | None = None diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 9462c597f23..08a68fa0164 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -37,7 +37,7 @@ class ConversationInput: language: str """Language of the request.""" - agent_id: str | None = None + agent_id: str """Agent to use for processing.""" extra_system_prompt: str | None = None diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 8a6c5563601..0f26c93da25 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -261,8 +261,6 @@ class GoogleGenerativeAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - - assert user_input.agent_id options = self.entry.options try: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 73dafa1c48d..eaa62bd1adc 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -198,7 +198,6 @@ class OpenAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - assert user_input.agent_id options = self.entry.options try: diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6900ba2d419..9ac5c7d16a4 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -271,6 +271,7 @@ async def test_async_handle_sentence_triggers( text="my trigger", context=Context(), conversation_id=None, + agent_id=conversation.HOME_ASSISTANT_AGENT, device_id=device_id, language=hass.config.language, ), @@ -306,6 +307,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None: ConversationInput( text="I'd like to order a stout", context=Context(), + agent_id=conversation.HOME_ASSISTANT_AGENT, conversation_id=None, device_id=None, language=hass.config.language, @@ -321,6 +323,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None: hass, ConversationInput( text="this sentence does not exist", + agent_id=conversation.HOME_ASSISTANT_AGENT, context=Context(), conversation_id=None, device_id=None, diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 9b57bb43b58..3aa8ae2939f 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -5,7 +5,7 @@ import logging import pytest import voluptuous as vol -from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, default_agent from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.models import ConversationInput from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -82,7 +82,7 @@ async def test_if_fires_on_event( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -230,7 +230,7 @@ async def test_response_same_sentence( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -408,7 +408,7 @@ async def test_same_trigger_multiple_sentences( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -636,7 +636,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) }, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, From aa19207ea4a12abc592781baeff674027ece33dd Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 7 Feb 2025 19:41:09 -0800 Subject: [PATCH 290/410] Clear statistics when you unload the Opower integration (#135908) * Clear statistics when you remove the Opower integration * fix --- homeassistant/components/opower/__init__.py | 6 +----- homeassistant/components/opower/coordinator.py | 14 ++++++++++++++ homeassistant/components/opower/sensor.py | 3 +-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 136a1a4e57a..b8e4f4381d0 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -2,18 +2,14 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 6957ae4984c..8d7ef1ace94 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -23,6 +23,7 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed @@ -34,10 +35,14 @@ from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) +type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] + class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Handle fetching Opower data, updating sensors and inserting statistics.""" + config_entry: OpowerConfigEntry + def __init__( self, hass: HomeAssistant, @@ -59,6 +64,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data[CONF_PASSWORD], entry_data.get(CONF_TOTP_SECRET), ) + self._statistic_ids: set[str] = set() @callback def _dummy_listener() -> None: @@ -70,6 +76,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # _async_update_data not periodically getting called which is needed for _insert_statistics. self.async_add_listener(_dummy_listener) + self.config_entry.async_on_unload(self._clear_statistics) + + def _clear_statistics(self) -> None: + """Clear statistics.""" + get_instance(self.hass).async_clear_statistics(list(self._statistic_ids)) + async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -115,6 +127,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + self._statistic_ids.add(cost_statistic_id) + self._statistic_ids.add(consumption_statistic_id) _LOGGER.debug( "Updating Statistics for %s and %s", cost_statistic_id, diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index f9d0fe62332..1b3aa0fd710 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -20,9 +20,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OpowerConfigEntry from .const import DOMAIN -from .coordinator import OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator @dataclass(frozen=True, kw_only=True) From f64b494282e416cfafc4d2c27cd3ff4d242e2569 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Feb 2025 01:06:16 -0500 Subject: [PATCH 291/410] Conversation chat log cleanup and optimization (#137784) --- .../components/assist_pipeline/pipeline.py | 52 ++++++------ .../components/conversation/chat_log.py | 79 +++++++++++++------ .../components/conversation/test_chat_log.py | 62 +++++++++++---- 3 files changed, 130 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 94e2b04d7ae..ef26e1a5a6d 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1093,16 +1093,18 @@ class PipelineRun: agent_id = conversation.HOME_ASSISTANT_AGENT processed_locally = True - # It was already handled, create response and add to chat history - if intent_response is not None: - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log( - self.hass, session, user_input - ) as chat_log, - ): + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log( + self.hass, + session, + user_input, + ) as chat_log, + ): + # It was already handled, create response and add to chat history + if intent_response is not None: speech: str = intent_response.speech.get("plain", {}).get( "speech", "" ) @@ -1117,21 +1119,21 @@ class PipelineRun: conversation_id=session.conversation_id, ) - else: - # Fall back to pipeline conversation agent - conversation_result = await conversation.async_converse( - hass=self.hass, - text=user_input.text, - conversation_id=user_input.conversation_id, - device_id=user_input.device_id, - 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", "" - ) + else: + # Fall back to pipeline conversation agent + conversation_result = await conversation.async_converse( + hass=self.hass, + text=user_input.text, + conversation_id=user_input.conversation_id, + device_id=user_input.device_id, + 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", "" + ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index ad7a9d0ce9e..e4ff1904e7c 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -1,9 +1,11 @@ -"""Conversation history.""" +"""Conversation chat log.""" from __future__ import annotations +import asyncio from collections.abc import AsyncGenerator, Generator from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass, field, replace import logging @@ -19,10 +21,14 @@ from . import trace from .const import DOMAIN from .models import ConversationInput, ConversationResult -DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log") +DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") LOGGER = logging.getLogger(__name__) +current_chat_log: ContextVar[ChatLog | None] = ContextVar( + "current_chat_log", default=None +) + @contextmanager def async_get_chat_log( @@ -31,41 +37,50 @@ def async_get_chat_log( user_input: ConversationInput | None = None, ) -> Generator[ChatLog]: """Return chat log for a specific chat session.""" - all_history = hass.data.get(DATA_CHAT_HISTORY) - if all_history is None: - all_history = {} - hass.data[DATA_CHAT_HISTORY] = all_history + if chat_log := current_chat_log.get(): + # If a chat log is already active and it's the requested conversation ID, + # return that. We won't update the last updated time in this case. + if chat_log.conversation_id == session.conversation_id: + yield chat_log + return - history = all_history.get(session.conversation_id) + all_chat_logs = hass.data.get(DATA_CHAT_LOGS) + if all_chat_logs is None: + all_chat_logs = {} + hass.data[DATA_CHAT_LOGS] = all_chat_logs - if history: - history = replace(history, content=history.content.copy()) + chat_log = all_chat_logs.get(session.conversation_id) + + if chat_log: + chat_log = replace(chat_log, content=chat_log.content.copy()) else: - history = ChatLog(hass, session.conversation_id) + chat_log = ChatLog(hass, session.conversation_id) if user_input is not None: - history.async_add_user_content(UserContent(content=user_input.text)) + chat_log.async_add_user_content(UserContent(content=user_input.text)) - last_message = history.content[-1] + last_message = chat_log.content[-1] - yield history + token = current_chat_log.set(chat_log) + yield chat_log + current_chat_log.reset(token) - if history.content[-1] is last_message: + if chat_log.content[-1] is last_message: LOGGER.debug( - "History opened but no assistant message was added, ignoring update" + "Chat Log opened but no assistant message was added, ignoring update" ) return - if session.conversation_id not in all_history: + if session.conversation_id not in all_chat_logs: @callback def do_cleanup() -> None: """Handle cleanup.""" - all_history.pop(session.conversation_id) + all_chat_logs.pop(session.conversation_id) session.async_on_cleanup(do_cleanup) - all_history[session.conversation_id] = history + all_chat_logs[session.conversation_id] = chat_log class ConverseError(HomeAssistantError): @@ -112,7 +127,7 @@ class AssistantContent: role: str = field(init=False, default="assistant") agent_id: str - content: str + content: str | None = None tool_calls: list[llm.ToolInput] | None = None @@ -143,6 +158,7 @@ class ChatLog: @callback def async_add_user_content(self, content: UserContent) -> None: """Add user content to the log.""" + LOGGER.debug("Adding user content: %s", content) self.content.append(content) @callback @@ -150,14 +166,24 @@ class ChatLog: self, content: AssistantContent ) -> None: """Add assistant content to the log.""" + LOGGER.debug("Adding assistant content: %s", content) if content.tool_calls is not None: raise ValueError("Tool calls not allowed") self.content.append(content) async def async_add_assistant_content( - self, content: AssistantContent + self, + content: AssistantContent, + /, + tool_call_tasks: dict[str, asyncio.Task] | None = None, ) -> AsyncGenerator[ToolResultContent]: - """Add assistant content.""" + """Add assistant content and execute tool calls. + + tool_call_tasks can contains tasks for tool calls that are already in progress. + + This method is an async generator and will yield the tool results as they come in. + """ + LOGGER.debug("Adding assistant content: %s", content) self.content.append(content) if content.tool_calls is None: @@ -166,13 +192,22 @@ class ChatLog: if self.llm_api is None: raise ValueError("No LLM API configured") + if tool_call_tasks is None: + tool_call_tasks = {} + for tool_input in content.tool_calls: + if tool_input.id not in tool_call_tasks: + tool_call_tasks[tool_input.id] = self.hass.async_create_task( + self.llm_api.async_call_tool(tool_input), + name=f"llm_tool_{tool_input.id}", + ) + for tool_input in content.tool_calls: LOGGER.debug( "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args ) try: - tool_result = await self.llm_api.async_call_tool(tool_input) + tool_result = await tool_call_tasks[tool_input.id] except (HomeAssistantError, vol.Invalid) as e: tool_result = {"error": type(e).__name__} if str(e): diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c22a90e6928..1f659b8005e 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -15,7 +15,7 @@ from homeassistant.components.conversation import ( ToolResultContent, async_get_chat_log, ) -from homeassistant.components.conversation.chat_log import DATA_CHAT_HISTORY +from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, llm @@ -63,7 +63,7 @@ async def test_cleanup( ) ) - assert conversation_id in hass.data[DATA_CHAT_HISTORY] + assert conversation_id in hass.data[DATA_CHAT_LOGS] # Set the last updated to be older than the timeout hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = ( @@ -75,7 +75,7 @@ async def test_cleanup( dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1), ) - assert conversation_id not in hass.data[DATA_CHAT_HISTORY] + assert conversation_id not in hass.data[DATA_CHAT_LOGS] async def test_default_content( @@ -279,9 +279,18 @@ async def test_extra_systen_prompt( assert chat_log.content[0].content.endswith(extra_system_prompt2) +@pytest.mark.parametrize( + "prerun_tool_tasks", + [ + None, + ("mock-tool-call-id",), + ("mock-tool-call-id", "mock-tool-call-id-2"), + ], +) async def test_tool_call( hass: HomeAssistant, mock_conversation_input: ConversationInput, + prerun_tool_tasks: tuple[str] | None, ) -> None: """Test using the session tool calling API.""" @@ -316,26 +325,47 @@ async def test_tool_call( id="mock-tool-call-id", tool_name="test_tool", tool_args={"param1": "Test Param"}, - ) + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), ], ) + tool_call_tasks = None + if prerun_tool_tasks: + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, + ) + for tool_call_id in prerun_tool_tasks + } + with pytest.raises(ValueError): chat_log.async_add_assistant_content_without_tools(content) - result = None - async for tool_result_content in chat_log.async_add_assistant_content( - content - ): - assert result is None - result = tool_result_content + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks + ) + ] - assert result == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id", - tool_result="Test response", - tool_name="test_tool", - ) + assert results[0] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + assert results[1] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id-2", + tool_result="Test response", + tool_name="test_tool", + ) async def test_tool_call_exception( From 0e0129968bef32f35ccd1ad1bca55b22d4543bec Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Feb 2025 07:38:49 +0100 Subject: [PATCH 292/410] Bump onedrive_personal_sdk to 0.0.9 (#137729) --- homeassistant/components/onedrive/__init__.py | 2 +- homeassistant/components/onedrive/backup.py | 2 +- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 9716f692ec8..8355cddb0b5 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -133,7 +133,7 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - metadata_file = await client.upload_file( backup_folder_id, metadata_filename, - dumps(metadata), # type: ignore[arg-type] + dumps(metadata), ) metadata_description = { "metadata_version": 2, diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 182e29aa63f..9926bd9cbc7 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -168,7 +168,7 @@ class OneDriveBackupAgent(BackupAgent): metadata_file = await self._client.upload_file( self._folder_id, metadata_filename, - description, # type: ignore[arg-type] + description, ) # add metadata to the metadata file diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 88d51e6d73a..fcc922b3e46 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.8"] + "requirements": ["onedrive-personal-sdk==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 306e5891c86..fe7ea7fc5ce 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.8 +onedrive-personal-sdk==0.0.9 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efe7b9d5418..a686bbd0633 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.8 +onedrive-personal-sdk==0.0.9 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 6c74824ac11d7fe53620fd8ab39cc8b9e9e0a7d5 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Sat, 8 Feb 2025 08:51:33 +0100 Subject: [PATCH 293/410] Add discovery for Nanoleaf Blocks and 4D (#137792) --- homeassistant/components/nanoleaf/manifest.json | 8 +++++++- homeassistant/generated/ssdp.py | 6 ++++++ homeassistant/generated/zeroconf.py | 8 ++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 4b4c026260d..7af7465bbd0 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", "homekit": { - "models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59"] + "models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59", "NL69", "NL81"] }, "iot_class": "local_push", "loggers": ["aionanoleaf"], @@ -22,6 +22,12 @@ }, { "st": "nanoleaf:nl52" + }, + { + "st": "nanoleaf:nl69" + }, + { + "st": "inanoleaf:nl81" } ], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."] diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 89d1aa30cb8..5bbc178ba17 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -211,6 +211,12 @@ SSDP = { { "st": "nanoleaf:nl52", }, + { + "st": "nanoleaf:nl69", + }, + { + "st": "inanoleaf:nl81", + }, ], "netgear": [ { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8244f19660f..ab965e27472 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -208,6 +208,14 @@ HOMEKIT = { "always_discover": False, "domain": "nanoleaf", }, + "NL69": { + "always_discover": False, + "domain": "nanoleaf", + }, + "NL81": { + "always_discover": False, + "domain": "nanoleaf", + }, "Netatmo Relay": { "always_discover": True, "domain": "netatmo", From 5f5ad34176a70569274618e028679b100ec93687 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 7 Feb 2025 23:59:20 -0800 Subject: [PATCH 294/410] Info log when Android TV Remote is unavailable (#137794) --- .../components/androidtv_remote/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 6a55e9971ac..28a372da4ea 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -35,16 +35,12 @@ async def async_setup_entry( @callback def is_available_updated(is_available: bool) -> None: - if is_available: - _LOGGER.info( - "Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST] - ) - else: - _LOGGER.warning( - "Disconnected from %s at %s", - entry.data[CONF_NAME], - entry.data[CONF_HOST], - ) + _LOGGER.info( + "%s %s at %s", + "Reconnected to" if is_available else "Disconnected from", + entry.data[CONF_NAME], + entry.data[CONF_HOST], + ) api.add_is_available_updated_callback(is_available_updated) From 64886f717d3a7e7737cf5b0ca497996fc2ab9aae Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Sat, 8 Feb 2025 08:59:47 +0100 Subject: [PATCH 295/410] Add quality_scale to motionmount (#137012) * Mark as ready for Bronze * Add rest of quality scale rules * Evaluate all quality scale rules * Exempt repair-issues * Update dynamic-devices comment Co-authored-by: Josef Zweck --------- Co-authored-by: Josef Zweck --- .../components/motionmount/manifest.json | 1 + .../components/motionmount/quality_scale.yaml | 78 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/motionmount/quality_scale.yaml diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 422be417006..2665836ffd4 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "bronze", "requirements": ["python-MotionMount==2.3.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml new file mode 100644 index 00000000000..e4a6a04ceeb --- /dev/null +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: Integration does register actions aside from entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single device per config entry + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not need user intervention + stale-devices: + status: exempt + comment: Integration does not support dynamic devices + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Device doesn't make http requests. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index a1ad52e6aa8..e5eee2f4157 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -669,7 +669,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "motion_blinds", "motionblinds_ble", "motioneye", - "motionmount", "mpd", "mqtt_eventstream", "mqtt_json", @@ -1748,7 +1747,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "motion_blinds", "motionblinds_ble", "motioneye", - "motionmount", "mpd", "mqtt_eventstream", "mqtt_json", From 332a0c5082a2e6bee7aea52f285dd47910c471ba Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 8 Feb 2025 03:14:00 -0500 Subject: [PATCH 296/410] LaCrosse View new endpoint (#137284) * Switch to new endpoint in LaCrosse View * Coverage * Avoid merge conflict * Switch to UpdateFailed --- .../components/lacrosse_view/coordinator.py | 37 ++++++---- .../components/lacrosse_view/sensor.py | 2 +- tests/components/lacrosse_view/__init__.py | 67 ++++++++++++++++--- .../snapshots/test_diagnostics.ambr | 2 +- .../lacrosse_view/test_diagnostics.py | 7 +- tests/components/lacrosse_view/test_init.py | 31 ++++++--- tests/components/lacrosse_view/test_sensor.py | 50 +++++++++++--- 7 files changed, 151 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 5ec02a86709..8d7e44ecd99 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -10,8 +10,8 @@ from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import SCAN_INTERVAL @@ -26,6 +26,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): name: str id: str hass: HomeAssistant + devices: list[Sensor] | None = None def __init__( self, @@ -60,24 +61,34 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): except LoginError as error: raise ConfigEntryAuthFailed from error + if self.devices is None: + _LOGGER.debug("Getting devices") + try: + self.devices = await self.api.get_devices( + location=Location(id=self.id, name=self.name), + ) + except HTTPError as error: + raise UpdateFailed from error + try: # Fetch last hour of data - sensors = await self.api.get_sensors( - location=Location(id=self.id, name=self.name), - tz=self.hass.config.time_zone, - start=str(now - 3600), - end=str(now), - ) - except HTTPError as error: - raise ConfigEntryNotReady from error + for sensor in self.devices: + sensor.data = ( + await self.api.get_sensor_status( + sensor=sensor, + tz=self.hass.config.time_zone, + ) + )["data"]["current"] + _LOGGER.debug("Got data: %s", sensor.data) - _LOGGER.debug("Got data: %s", sensors) + except HTTPError as error: + raise UpdateFailed from error # Verify that we have permission to read the sensors - for sensor in sensors: + for sensor in self.devices: if not sensor.permissions.get("read", False): raise ConfigEntryAuthFailed( f"This account does not have permission to read {sensor.name}" ) - return sensors + return self.devices diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index fceddeb9b2c..5c56a0328a2 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -48,7 +48,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: 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"] + value = field_data["spot"]["value"] try: value = float(value) except ValueError: diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 913f6c72f24..860156beb6c 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -15,7 +15,13 @@ TEST_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -26,7 +32,13 @@ TEST_NO_PERMISSION_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": False}, model="Test", ) @@ -37,7 +49,16 @@ TEST_UNSUPPORTED_SENSOR = Sensor( sensor_id="2", sensor_field_names=["SomeUnsupportedField"], location=Location(id="1", name="Test"), - data={"SomeUnsupportedField": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "SomeUnsupportedField": { + "spot": {"value": "2"}, + "unit": "degrees_celsius", + } + } + } + }, permissions={"read": True}, model="Test", ) @@ -48,7 +69,13 @@ TEST_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.3"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.3"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -59,7 +86,9 @@ TEST_STRING_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WetDry"], location=Location(id="1", name="Test"), - data={"WetDry": {"values": [{"s": "dry"}], "unit": "wet_dry"}}, + data={ + "data": {"current": {"WetDry": {"spot": {"value": "dry"}, "unit": "wet_dry"}}} + }, permissions={"read": True}, model="Test", ) @@ -70,7 +99,13 @@ TEST_ALREADY_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "HeatIndex": {"spot": {"value": 2.3}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -81,7 +116,13 @@ TEST_ALREADY_INT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, + data={ + "data": { + "current": { + "WindSpeed": {"spot": {"value": 2}, "unit": "kilometers_per_hour"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -92,7 +133,7 @@ TEST_NO_FIELD_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={}, + data={"data": {"current": {}}}, permissions={"read": True}, model="Test", ) @@ -103,7 +144,7 @@ TEST_MISSING_FIELD_DATA_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": None}, + data={"data": {"current": {"Temperature": None}}}, permissions={"read": True}, model="Test", ) @@ -114,7 +155,13 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.1"}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 201bbbc971e..bfbfa2901a6 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -4,7 +4,7 @@ 'coordinator_data': list([ dict({ '__type': "", - 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'values': [{'s': '2'}], 'unit': 'degrees_celsius'}})", + 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'spot': {'value': '2'}, 'unit': 'degrees_celsius'}})", }), ]), 'entry': dict({ diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py index dc48f160113..4306173c6b3 100644 --- a/tests/components/lacrosse_view/test_diagnostics.py +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -26,9 +26,14 @@ async def test_entry_diagnostics( ) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 51fa7e5abf4..af92d0e64f1 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -20,12 +20,17 @@ async def test_unload_entry(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -68,7 +73,7 @@ async def test_http_error(hass: HomeAssistant) -> None: with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", side_effect=HTTPError), + patch("lacrosse_view.LaCrosse.get_devices", side_effect=HTTPError), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -84,12 +89,17 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -103,7 +113,7 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR], ), ): @@ -121,12 +131,17 @@ async def test_failed_token( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 11faaf8877e..74e9f001792 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -32,9 +32,14 @@ async def test_entities_added(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,12 +59,17 @@ async def test_sensor_permission( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_PERMISSION_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_PERMISSION_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -79,11 +89,14 @@ async def test_field_not_supported( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_UNSUPPORTED_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch( - "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_UNSUPPORTED_SENSOR] - ), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -114,12 +127,17 @@ async def test_field_types( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = test_input.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[test_input], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -137,12 +155,17 @@ async def test_no_field(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_FIELD_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_FIELD_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -160,12 +183,17 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_MISSING_FIELD_DATA_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_MISSING_FIELD_DATA_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 6cb020103183834d6221a7eb8650b86c429a6e6b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 09:57:00 +0100 Subject: [PATCH 297/410] Limit google_sheets ConfigEntrySelect to integration domain (#137766) --- homeassistant/components/google_sheets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 942db675b5a..faf1ff1ee0b 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -39,7 +39,7 @@ SERVICE_APPEND_SHEET = "append_sheet" SHEET_SERVICE_SCHEMA = vol.All( { - vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Optional(WORKSHEET): cv.string, vol.Required(DATA): vol.Any(cv.ensure_list, [dict]), }, From ae55e2654607c5da51cb17654e0b20ab033a4c9b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sat, 8 Feb 2025 10:07:22 +0100 Subject: [PATCH 298/410] Group helpers of set_up_integrations in bootstrap (#137673) --- homeassistant/bootstrap.py | 212 ++++++++++++++++++------------------- tests/test_bootstrap.py | 10 +- 2 files changed, 111 insertions(+), 111 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 490ce5559a9..58150ae7926 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -716,109 +716,6 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: return domains -class _WatchPendingSetups: - """Periodic log and dispatch of setups that are pending.""" - - def __init__( - self, - hass: core.HomeAssistant, - setup_started: dict[tuple[str, str | None], float], - ) -> None: - """Initialize the WatchPendingSetups class.""" - self._hass = hass - self._setup_started = setup_started - self._duration_count = 0 - self._handle: asyncio.TimerHandle | None = None - self._previous_was_empty = True - self._loop = hass.loop - - def _async_watch(self) -> None: - """Periodic log of setups that are pending.""" - now = monotonic() - self._duration_count += SLOW_STARTUP_CHECK_INTERVAL - - remaining_with_setup_started: defaultdict[str, float] = defaultdict(float) - for integration_group, start_time in self._setup_started.items(): - domain, _ = integration_group - remaining_with_setup_started[domain] += now - start_time - - if remaining_with_setup_started: - _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) - elif waiting_tasks := self._hass._active_tasks: # noqa: SLF001 - _LOGGER.debug("Waiting on tasks: %s", waiting_tasks) - self._async_dispatch(remaining_with_setup_started) - if ( - self._setup_started - and self._duration_count % LOG_SLOW_STARTUP_INTERVAL == 0 - ): - # We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done - # once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up - _LOGGER.warning( - "Waiting on integrations to complete setup: %s", - self._setup_started, - ) - - _LOGGER.debug("Running timeout Zones: %s", self._hass.timeout.zones) - self._async_schedule_next() - - def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None: - """Dispatch the signal.""" - if remaining_with_setup_started or not self._previous_was_empty: - async_dispatcher_send_internal( - self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started - ) - self._previous_was_empty = not remaining_with_setup_started - - def _async_schedule_next(self) -> None: - """Schedule the next call.""" - self._handle = self._loop.call_later( - SLOW_STARTUP_CHECK_INTERVAL, self._async_watch - ) - - def async_start(self) -> None: - """Start watching.""" - self._async_schedule_next() - - def async_stop(self) -> None: - """Stop watching.""" - self._async_dispatch({}) - if self._handle: - self._handle.cancel() - self._handle = None - - -async def async_setup_multi_components( - hass: core.HomeAssistant, - domains: set[str], - config: dict[str, Any], -) -> None: - """Set up multiple domains. Log on failure.""" - # Avoid creating tasks for domains that were setup in a previous stage - domains_not_yet_setup = domains - hass.config.components - # Create setup tasks for base platforms first since everything will have - # to wait to be imported, and the sooner we can get the base platforms - # loaded the sooner we can start loading the rest of the integrations. - futures = { - domain: hass.async_create_task_internal( - async_setup_component(hass, domain, config), - f"setup component {domain}", - eager_start=True, - ) - for domain in sorted( - domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True - ) - } - results = await asyncio.gather(*futures.values(), return_exceptions=True) - for idx, domain in enumerate(futures): - result = results[idx] - if isinstance(result, BaseException): - _LOGGER.error( - "Error setting up integration %s - received exception", - domain, - exc_info=(type(result), result, result.__traceback__), - ) - - async def _async_resolve_domains_to_setup( hass: core.HomeAssistant, config: dict[str, Any] ) -> tuple[set[str], dict[str, loader.Integration]]: @@ -1038,7 +935,7 @@ async def _async_set_up_integrations( for dep in integration.all_dependencies ) async_set_domains_to_be_loaded(hass, to_be_loaded) - await async_setup_multi_components(hass, domain_group, config) + await _async_setup_multi_components(hass, domain_group, config) # Enables after dependencies when setting up stage 1 domains async_set_domains_to_be_loaded(hass, stage_1_domains) @@ -1050,7 +947,7 @@ async def _async_set_up_integrations( async with hass.timeout.async_timeout( STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME ): - await async_setup_multi_components(hass, stage_1_domains, config) + await _async_setup_multi_components(hass, stage_1_domains, config) except TimeoutError: _LOGGER.warning( "Setup timed out for stage 1 waiting on %s - moving forward", @@ -1066,7 +963,7 @@ async def _async_set_up_integrations( async with hass.timeout.async_timeout( STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME ): - await async_setup_multi_components(hass, stage_2_domains, config) + await _async_setup_multi_components(hass, stage_2_domains, config) except TimeoutError: _LOGGER.warning( "Setup timed out for stage 2 waiting on %s - moving forward", @@ -1092,3 +989,106 @@ async def _async_set_up_integrations( "Integration setup times: %s", dict(sorted(setup_time.items(), key=itemgetter(1), reverse=True)), ) + + +class _WatchPendingSetups: + """Periodic log and dispatch of setups that are pending.""" + + def __init__( + self, + hass: core.HomeAssistant, + setup_started: dict[tuple[str, str | None], float], + ) -> None: + """Initialize the WatchPendingSetups class.""" + self._hass = hass + self._setup_started = setup_started + self._duration_count = 0 + self._handle: asyncio.TimerHandle | None = None + self._previous_was_empty = True + self._loop = hass.loop + + def _async_watch(self) -> None: + """Periodic log of setups that are pending.""" + now = monotonic() + self._duration_count += SLOW_STARTUP_CHECK_INTERVAL + + remaining_with_setup_started: defaultdict[str, float] = defaultdict(float) + for integration_group, start_time in self._setup_started.items(): + domain, _ = integration_group + remaining_with_setup_started[domain] += now - start_time + + if remaining_with_setup_started: + _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) + elif waiting_tasks := self._hass._active_tasks: # noqa: SLF001 + _LOGGER.debug("Waiting on tasks: %s", waiting_tasks) + self._async_dispatch(remaining_with_setup_started) + if ( + self._setup_started + and self._duration_count % LOG_SLOW_STARTUP_INTERVAL == 0 + ): + # We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done + # once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up + _LOGGER.warning( + "Waiting on integrations to complete setup: %s", + self._setup_started, + ) + + _LOGGER.debug("Running timeout Zones: %s", self._hass.timeout.zones) + self._async_schedule_next() + + def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None: + """Dispatch the signal.""" + if remaining_with_setup_started or not self._previous_was_empty: + async_dispatcher_send_internal( + self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started + ) + self._previous_was_empty = not remaining_with_setup_started + + def _async_schedule_next(self) -> None: + """Schedule the next call.""" + self._handle = self._loop.call_later( + SLOW_STARTUP_CHECK_INTERVAL, self._async_watch + ) + + def async_start(self) -> None: + """Start watching.""" + self._async_schedule_next() + + def async_stop(self) -> None: + """Stop watching.""" + self._async_dispatch({}) + if self._handle: + self._handle.cancel() + self._handle = None + + +async def _async_setup_multi_components( + hass: core.HomeAssistant, + domains: set[str], + config: dict[str, Any], +) -> None: + """Set up multiple domains. Log on failure.""" + # Avoid creating tasks for domains that were setup in a previous stage + domains_not_yet_setup = domains - hass.config.components + # Create setup tasks for base platforms first since everything will have + # to wait to be imported, and the sooner we can get the base platforms + # loaded the sooner we can start loading the rest of the integrations. + futures = { + domain: hass.async_create_task_internal( + async_setup_component(hass, domain, config), + f"setup component {domain}", + eager_start=True, + ) + for domain in sorted( + domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True + ) + } + results = await asyncio.gather(*futures.values(), return_exceptions=True) + for idx, domain in enumerate(futures): + result = results[idx] + if isinstance(result, BaseException): + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(result), result, result.__traceback__), + ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 5adfe4fc40b..4317df6cf4a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1176,7 +1176,7 @@ async def test_bootstrap_is_cancellation_safe( @pytest.mark.parametrize("load_registries", [False]) async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: """Test setting up an empty integrations does not raise.""" - await bootstrap.async_setup_multi_components(hass, set(), {}) + await bootstrap._async_setup_multi_components(hass, set(), {}) await hass.async_block_till_done() @@ -1311,7 +1311,7 @@ async def test_bootstrap_dependencies( ), ): bootstrap.async_set_domains_to_be_loaded(hass, {integration}) - await bootstrap.async_setup_multi_components(hass, {integration}, {}) + await bootstrap._async_setup_multi_components(hass, {integration}, {}) await hass.async_block_till_done() for assertion in assertions: @@ -1407,7 +1407,7 @@ async def test_cancellation_does_not_leak_upward_from_async_setup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting up an integration that raises asyncio.CancelledError.""" - await bootstrap.async_setup_multi_components( + await bootstrap._async_setup_multi_components( hass, {"test_package_raises_cancelled_error"}, {} ) await hass.async_block_till_done() @@ -1428,12 +1428,12 @@ async def test_cancellation_does_not_leak_upward_from_async_setup_entry( domain="test_package_raises_cancelled_error_config_entry", data={} ) entry.add_to_hass(hass) - await bootstrap.async_setup_multi_components( + await bootstrap._async_setup_multi_components( hass, {"test_package_raises_cancelled_error_config_entry"}, {} ) await hass.async_block_till_done() - await bootstrap.async_setup_multi_components(hass, {"test_package"}, {}) + await bootstrap._async_setup_multi_components(hass, {"test_package"}, {}) await hass.async_block_till_done() assert ( "Error setting up entry Mock Title for test_package_raises_cancelled_error_config_entry" From c370fa0489557caad51ef4b02bae7a54552eb4a5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Feb 2025 01:16:10 -0800 Subject: [PATCH 299/410] Use the external URL set in Settings > System > Network if my is disabled as redirect URL for Google Drive instructions (#137791) * Use the Assistant URL set in Settings > System > Network if my is disabled * fix * Remove async_get_redirect_uri --- .../google_drive/application_credentials.py | 12 +++++-- .../test_application_credentials.py | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/components/google_drive/test_application_credentials.py diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index 1c4421623d4..8bcab2b039c 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,7 +2,10 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + AUTH_CALLBACK_PATH, + MY_AUTH_CALLBACK_PATH, +) async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -15,9 +18,14 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: """Return description placeholders for the credentials dialog.""" + if "my" in hass.config.components: + redirect_url = MY_AUTH_CALLBACK_PATH + else: + ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT" + redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}" return { "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), + "redirect_url": redirect_url, } diff --git a/tests/components/google_drive/test_application_credentials.py b/tests/components/google_drive/test_application_credentials.py new file mode 100644 index 00000000000..ec46db510a5 --- /dev/null +++ b/tests/components/google_drive/test_application_credentials.py @@ -0,0 +1,36 @@ +"""Test the Google Drive application_credentials.""" + +import pytest + +from homeassistant import setup +from homeassistant.components.google_drive.application_credentials import ( + async_get_description_placeholders, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("additional_components", "external_url", "expected_redirect_uri"), + [ + ([], "https://example.com", "https://example.com/auth/external/callback"), + ([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"), + (["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"), + ], +) +async def test_description_placeholders( + hass: HomeAssistant, + additional_components: list[str], + external_url: str | None, + expected_redirect_uri: str, +) -> None: + """Test description placeholders.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) + hass.config.external_url = external_url + placeholders = await async_get_description_placeholders(hass) + assert placeholders == { + "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": expected_redirect_uri, + } From 327811c89aad4b9af0baedddc8b05ed3f79a9b78 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 10:53:13 +0100 Subject: [PATCH 300/410] Explicitly pass in the config_entry in co2signal coordinator (#137732) explicitly pass in the config_entry in coordinator --- homeassistant/components/co2signal/__init__.py | 7 ++----- .../components/co2signal/coordinator.py | 17 ++++++++++++++--- .../components/co2signal/diagnostics.py | 2 +- homeassistant/components/co2signal/sensor.py | 3 +-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index e84ba387194..612610eff43 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -4,23 +4,20 @@ from __future__ import annotations from aioelectricitymaps import ElectricityMaps -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import CO2SignalCoordinator +from .coordinator import CO2SignalConfigEntry, CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] -type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: CO2SignalConfigEntry) -> bool: """Set up CO2 Signal from a config entry.""" session = async_get_clientsession(hass) coordinator = CO2SignalCoordinator( - hass, ElectricityMaps(token=entry.data[CONF_API_KEY], session=session) + hass, entry, ElectricityMaps(token=entry.data[CONF_API_KEY], session=session) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 475ebd1225d..be2036292e3 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -22,16 +22,27 @@ from .helpers import fetch_latest_carbon_intensity _LOGGER = logging.getLogger(__name__) +type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] + class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): """Data update coordinator.""" - config_entry: ConfigEntry + config_entry: CO2SignalConfigEntry - def __init__(self, hass: HomeAssistant, client: ElectricityMaps) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: CO2SignalConfigEntry, + client: ElectricityMaps, + ) -> None: """Initialize the coordinator.""" super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=15), ) self.client = client diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index a071950440f..840ba759a7b 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from . import CO2SignalConfigEntry +from .coordinator import CO2SignalConfigEntry TO_REDACT = {CONF_API_KEY} diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 1b964edf591..92f88b8ae82 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -18,9 +18,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CO2SignalConfigEntry from .const import ATTRIBUTION, DOMAIN -from .coordinator import CO2SignalCoordinator +from .coordinator import CO2SignalConfigEntry, CO2SignalCoordinator @dataclass(frozen=True, kw_only=True) From 32ef37cd5822e36c033e44ff20a944ef20107885 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 11:48:55 +0100 Subject: [PATCH 301/410] Explicitly pass in the config_entry in airq coordinator init (#137704) explicitly pass in the config_entry in airq coordinator init --- homeassistant/components/airq/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 362b65b5828..b48d8047910 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -22,6 +22,8 @@ _LOGGER = logging.getLogger(__name__) class AirQCoordinator(DataUpdateCoordinator): """Coordinator is responsible for querying the device at a specified route.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -33,6 +35,7 @@ class AirQCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) From 1522f7b3a870c2c3fe53257b48d4e5d2e9a9495b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:03:26 +0100 Subject: [PATCH 302/410] Explicitly pass in the config_entry in airzone_cloud coordinator init (#137703) explicitly pass in the config_entry in airzone_cloud coordinator init --- homeassistant/components/airzone_cloud/__init__.py | 7 ++----- .../components/airzone_cloud/binary_sensor.py | 3 +-- homeassistant/components/airzone_cloud/climate.py | 3 +-- .../components/airzone_cloud/coordinator.py | 13 ++++++++++++- .../components/airzone_cloud/diagnostics.py | 2 +- homeassistant/components/airzone_cloud/select.py | 3 +-- homeassistant/components/airzone_cloud/sensor.py | 3 +-- homeassistant/components/airzone_cloud/switch.py | 3 +-- .../components/airzone_cloud/water_heater.py | 3 +-- 9 files changed, 21 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 5baa0bcea10..a5a29263140 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -5,12 +5,11 @@ from __future__ import annotations from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.common import ConnectionOptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -21,8 +20,6 @@ PLATFORMS: list[Platform] = [ Platform.WATER_HEATER, ] -type AirzoneCloudConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry @@ -42,7 +39,7 @@ async def async_setup_entry( airzone.select_installation(inst) await airzone.update_installation(inst) - coordinator = AirzoneUpdateCoordinator(hass, airzone) + coordinator = AirzoneUpdateCoordinator(hass, entry, airzone) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 3d6f6b42901..4a7b5441b68 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -28,8 +28,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, AirzoneEntity, diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index b98473072e4..69b10d2a69e 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -58,8 +58,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, AirzoneEntity, diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py index e510dcfb401..840bfec0d1b 100644 --- a/homeassistant/components/airzone_cloud/coordinator.py +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -10,6 +10,7 @@ from typing import Any from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.exceptions import AirzoneCloudError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,11 +20,20 @@ SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +type AirzoneCloudConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] + class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Airzone Cloud device.""" - def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None: + config_entry: AirzoneCloudConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirzoneCloudConfigEntry, + airzone: AirzoneCloudApi, + ) -> None: """Initialize.""" self.airzone = airzone self.airzone.set_update_callback(self.async_set_updated_data) @@ -31,6 +41,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index b6744e36d8c..04aac7e2aa8 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -25,7 +25,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import AirzoneCloudConfigEntry +from .coordinator import AirzoneCloudConfigEntry TO_REDACT_API = [ API_CITY, diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py index 895796a1073..e0c595a80e8 100644 --- a/homeassistant/components/airzone_cloud/select.py +++ b/homeassistant/components/airzone_cloud/select.py @@ -23,8 +23,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 70d2fd079d4..4b13e09d126 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -49,8 +49,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, AirzoneEntity, diff --git a/homeassistant/components/airzone_cloud/switch.py b/homeassistant/components/airzone_cloud/switch.py index 0eb907ff792..8de0685e15e 100644 --- a/homeassistant/components/airzone_cloud/switch.py +++ b/homeassistant/components/airzone_cloud/switch.py @@ -15,8 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone_cloud/water_heater.py b/homeassistant/components/airzone_cloud/water_heater.py index 51228ae6b90..381dce913fe 100644 --- a/homeassistant/components/airzone_cloud/water_heater.py +++ b/homeassistant/components/airzone_cloud/water_heater.py @@ -31,8 +31,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneHotWaterEntity OPERATION_LIB_TO_HASS: Final[dict[HotWaterOperation, str]] = { From af87e36048a6654a1fd4fc8d77dfcc190be42283 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:12:07 +0100 Subject: [PATCH 303/410] Explicitly pass in the config_entry in fjaraskupan coordinator (#137825) explicitly pass in the config_entry in coordinator --- homeassistant/components/fjaraskupan/__init__.py | 2 +- .../components/fjaraskupan/coordinator.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index d95cb1d1006..2703fc5a30e 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -76,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator( - hass, device, device_info + hass, entry, device, device_info ) coordinator.detection_callback(service_info) diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py index 90b2c617239..bfea5e5f4fc 100644 --- a/homeassistant/components/fjaraskupan/coordinator.py +++ b/homeassistant/components/fjaraskupan/coordinator.py @@ -21,6 +21,7 @@ from homeassistant.components.bluetooth import ( async_address_present, async_ble_device_from_address, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -64,8 +65,14 @@ class UnableToConnect(HomeAssistantError): class FjaraskupanCoordinator(DataUpdateCoordinator[State]): """Update coordinator for each device.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, device: Device, device_info: DeviceInfo + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + device: Device, + device_info: DeviceInfo, ) -> None: """Initialize the coordinator.""" self.device = device @@ -73,7 +80,11 @@ class FjaraskupanCoordinator(DataUpdateCoordinator[State]): self._refresh_was_scheduled = False super().__init__( - hass, _LOGGER, name="Fjäråskupan", update_interval=timedelta(seconds=120) + hass, + _LOGGER, + config_entry=config_entry, + name="Fjäråskupan", + update_interval=timedelta(seconds=120), ) async def _async_refresh( From c71116cc12cb931654b6973e8a20471f51819328 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:46:05 +0100 Subject: [PATCH 304/410] Explicitly pass in the config_entry in aosmith coordinator init (#137710) explicitly pass in the config_entry in aosmith coordinator init --- homeassistant/components/aosmith/__init__.py | 25 ++++-------- .../components/aosmith/coordinator.py | 39 +++++++++++++++++-- .../components/aosmith/diagnostics.py | 2 +- homeassistant/components/aosmith/sensor.py | 7 +++- .../components/aosmith/water_heater.py | 3 +- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index dd60f69c4b9..7593365c573 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -2,31 +2,22 @@ from __future__ import annotations -from dataclasses import dataclass - from py_aosmith import AOSmithAPIClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN -from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .coordinator import ( + AOSmithConfigEntry, + AOSmithData, + AOSmithEnergyCoordinator, + AOSmithStatusCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] -type AOSmithConfigEntry = ConfigEntry[AOSmithData] - - -@dataclass -class AOSmithData: - """Data for the A. O. Smith integration.""" - - client: AOSmithAPIClient - status_coordinator: AOSmithStatusCoordinator - energy_coordinator: AOSmithEnergyCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool: """Set up A. O. Smith from a config entry.""" @@ -36,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> b session = aiohttp_client.async_get_clientsession(hass) client = AOSmithAPIClient(email, password, session) - status_coordinator = AOSmithStatusCoordinator(hass, client) + status_coordinator = AOSmithStatusCoordinator(hass, entry, client) await status_coordinator.async_config_entry_first_refresh() device_registry = dr.async_get(hass) @@ -53,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> b ) energy_coordinator = AOSmithEnergyCoordinator( - hass, client, list(status_coordinator.data) + hass, entry, client, list(status_coordinator.data) ) await energy_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 3bf97e49cae..26029fee750 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,5 +1,6 @@ """The data update coordinator for the A. O. Smith integration.""" +from dataclasses import dataclass import logging from py_aosmith import ( @@ -9,6 +10,7 @@ from py_aosmith import ( ) from py_aosmith.models import Device as AOSmithDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,13 +19,37 @@ from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVA _LOGGER = logging.getLogger(__name__) +type AOSmithConfigEntry = ConfigEntry[AOSmithData] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + client: AOSmithAPIClient + status_coordinator: "AOSmithStatusCoordinator" + energy_coordinator: "AOSmithEnergyCoordinator" + class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): """Coordinator for device status, updating with a frequent interval.""" - def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + config_entry: AOSmithConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AOSmithConfigEntry, + client: AOSmithAPIClient, + ) -> None: """Initialize the coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=REGULAR_INTERVAL, + ) self.client = client async def _async_update_data(self) -> dict[str, AOSmithDevice]: @@ -51,15 +77,22 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): """Coordinator for energy usage data, updating with a slower interval.""" + config_entry: AOSmithConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: AOSmithConfigEntry, client: AOSmithAPIClient, junction_ids: list[str], ) -> None: """Initialize the coordinator.""" super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=ENERGY_USAGE_INTERVAL + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=ENERGY_USAGE_INTERVAL, ) self.client = client self.junction_ids = junction_ids diff --git a/homeassistant/components/aosmith/diagnostics.py b/homeassistant/components/aosmith/diagnostics.py index 94726731f75..4019bee4dc8 100644 --- a/homeassistant/components/aosmith/diagnostics.py +++ b/homeassistant/components/aosmith/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import AOSmithConfigEntry +from .coordinator import AOSmithConfigEntry TO_REDACT = { "address", diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index b1c9852f647..8a7a98115fa 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -15,8 +15,11 @@ from homeassistant.const import PERCENTAGE, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AOSmithConfigEntry -from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .coordinator import ( + AOSmithConfigEntry, + AOSmithEnergyCoordinator, + AOSmithStatusCoordinator, +) from .entity import AOSmithEnergyEntity, AOSmithStatusEntity diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index f3dc8b3413f..110f997065b 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AOSmithConfigEntry -from .coordinator import AOSmithStatusCoordinator +from .coordinator import AOSmithConfigEntry, AOSmithStatusCoordinator from .entity import AOSmithStatusEntity MODE_HA_TO_AOSMITH = { From 92234f86e8da5f55ac265b0aa14592f91a78d290 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:46:51 +0100 Subject: [PATCH 305/410] Explicitly pass in the config_entry in aseko_pool_live coordinator init (#137711) explicitly pass in the config_entry in aseko_pool_live coordinator init --- homeassistant/components/aseko_pool_live/__init__.py | 2 +- homeassistant/components/aseko_pool_live/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py index 52d74398818..012b5a19b0f 100644 --- a/homeassistant/components/aseko_pool_live/__init__.py +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AsekoConfigEntry) -> boo except AsekoNotLoggedIn as err: raise ConfigEntryAuthFailed from err - coordinator = AsekoDataUpdateCoordinator(hass, aseko) + coordinator = AsekoDataUpdateCoordinator(hass, entry, aseko) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/aseko_pool_live/coordinator.py b/homeassistant/components/aseko_pool_live/coordinator.py index 96893912361..d54aa756ddd 100644 --- a/homeassistant/components/aseko_pool_live/coordinator.py +++ b/homeassistant/components/aseko_pool_live/coordinator.py @@ -21,13 +21,18 @@ type AsekoConfigEntry = ConfigEntry[AsekoDataUpdateCoordinator] class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Unit]]): """Class to manage fetching Aseko unit data from single endpoint.""" - def __init__(self, hass: HomeAssistant, aseko: Aseko) -> None: + config_entry: AsekoConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: AsekoConfigEntry, aseko: Aseko + ) -> None: """Initialize global Aseko unit data updater.""" self._aseko = aseko super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=2), ) From 4893cdaa80bb3a636b39877a362e6ef3b7ee946b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:47:36 +0100 Subject: [PATCH 306/410] Explicitly pass in the config_entry in aurora coordinator init (#137714) explicitly pass in the config_entry in aurora coordinator init --- homeassistant/components/aurora/__init__.py | 7 ++----- homeassistant/components/aurora/binary_sensor.py | 2 +- homeassistant/components/aurora/coordinator.py | 10 +++++----- homeassistant/components/aurora/sensor.py | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index b6c47cf36b2..a48d704141f 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,20 +1,17 @@ """The aurora component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD -from .coordinator import AuroraDataUpdateCoordinator +from .coordinator import AuroraConfigEntry, AuroraDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type AuroraConfigEntry = ConfigEntry[AuroraDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AuroraConfigEntry) -> bool: """Set up Aurora from a config entry.""" - coordinator = AuroraDataUpdateCoordinator(hass=hass) + coordinator = AuroraDataUpdateCoordinator(hass=hass, config_entry=entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index b8fb5002ff5..648f6de08c9 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraConfigEntry +from .coordinator import AuroraConfigEntry from .entity import AuroraEntity diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 9771cc53652..a7b87baec22 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -4,11 +4,11 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING from aiohttp import ClientError from auroranoaa import AuroraForecast +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -16,23 +16,23 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD -if TYPE_CHECKING: - from . import AuroraConfigEntry - _LOGGER = logging.getLogger(__name__) +type AuroraConfigEntry = ConfigEntry[AuroraDataUpdateCoordinator] + class AuroraDataUpdateCoordinator(DataUpdateCoordinator[int]): """Class to manage fetching data from the NOAA Aurora API.""" config_entry: AuroraConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: AuroraConfigEntry) -> None: """Initialize the data updater.""" super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name="Aurora", update_interval=timedelta(minutes=5), ) diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 35d39289598..ec1b82c3c4d 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -7,7 +7,7 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraConfigEntry +from .coordinator import AuroraConfigEntry from .entity import AuroraEntity From a797b09bcb42a1b7a591fedc6dd7d96047aad232 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:47:57 +0100 Subject: [PATCH 307/410] Explicitly pass in the config_entry in gardena_bluetooth coordinator (#137830) explicitly pass in the config_entry in coordinator --- .../components/gardena_bluetooth/__init__.py | 11 ++++++----- .../components/gardena_bluetooth/binary_sensor.py | 2 +- homeassistant/components/gardena_bluetooth/button.py | 2 +- .../components/gardena_bluetooth/coordinator.py | 7 +++++++ homeassistant/components/gardena_bluetooth/number.py | 3 +-- homeassistant/components/gardena_bluetooth/sensor.py | 3 +-- homeassistant/components/gardena_bluetooth/switch.py | 3 +-- homeassistant/components/gardena_bluetooth/valve.py | 3 +-- 8 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 47034e61fb9..34f72bf0a5a 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -10,7 +10,6 @@ from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation from gardena_bluetooth.exceptions import CommunicationFailure from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -18,7 +17,11 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator +from .coordinator import ( + DeviceUnavailable, + GardenaBluetoothConfigEntry, + GardenaBluetoothCoordinator, +) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -32,8 +35,6 @@ LOGGER = logging.getLogger(__name__) TIMEOUT = 20.0 DISCONNECT_DELAY = 5 -type GardenaBluetoothConfigEntry = ConfigEntry[GardenaBluetoothCoordinator] - def get_connection(hass: HomeAssistant, address: str) -> CachedConnection: """Set up a cached client that keeps connection after last use.""" @@ -80,7 +81,7 @@ async def async_setup_entry( ) coordinator = GardenaBluetoothCoordinator( - hass, LOGGER, client, uuids, device, address + hass, entry, LOGGER, client, uuids, device, address ) entry.runtime_data = coordinator diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index d3ae096e291..4ee3dd511e9 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry +from .coordinator import GardenaBluetoothConfigEntry from .entity import GardenaBluetoothDescriptorEntity diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 9d87cba2446..8390baa5943 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry +from .coordinator import GardenaBluetoothConfigEntry from .entity import GardenaBluetoothDescriptorEntity diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 5caafe0e794..f85fb839657 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -12,6 +12,7 @@ from gardena_bluetooth.exceptions import ( ) from gardena_bluetooth.parse import Characteristic, CharacteristicType +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -20,6 +21,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda SCAN_INTERVAL = timedelta(seconds=60) LOGGER = logging.getLogger(__name__) +type GardenaBluetoothConfigEntry = ConfigEntry[GardenaBluetoothCoordinator] + class DeviceUnavailable(HomeAssistantError): """Raised if device can't be found.""" @@ -28,9 +31,12 @@ class DeviceUnavailable(HomeAssistantError): class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): """Class to manage fetching data.""" + config_entry: GardenaBluetoothConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GardenaBluetoothConfigEntry, logger: logging.Logger, client: Client, characteristics: set[str], @@ -41,6 +47,7 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): super().__init__( hass=hass, logger=logger, + config_entry=config_entry, name="Gardena Bluetooth Data Update Coordinator", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index b55630fa797..eb95d9ff814 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -21,8 +21,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index c07d2ba6866..29d1a3155de 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -19,8 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index f82c39025a5..73c4867d040 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -11,8 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothEntity diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index ae6bf56a7ff..e51e5aa22ca 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -10,8 +10,7 @@ from homeassistant.components.valve import ValveEntity, ValveEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothEntity FALLBACK_WATERING_TIME_IN_SECONDS = 60 * 60 From 36a0c49ceebdb3c8d8896dc1fd5bc038e8751717 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:13 +0100 Subject: [PATCH 308/410] Explicitly pass in the config_entry in gree coordinator (#137844) explicitly pass in the config_entry in coordinator --- homeassistant/components/gree/__init__.py | 2 +- homeassistant/components/gree/coordinator.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index c385ce45262..7cb4f0f0921 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -26,7 +26,7 @@ PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Gree Climate from a config entry.""" hass.data.setdefault(DOMAIN, {}) - gree_discovery = DiscoveryService(hass) + gree_discovery = DiscoveryService(hass, entry) hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery async def _async_scan_update(_=None): diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 42d6734a6b2..0d1aa60deaa 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -11,6 +11,7 @@ from greeclimate.discovery import Discovery, Listener from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError from greeclimate.network import Response +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.json import json_dumps @@ -32,12 +33,16 @@ _LOGGER = logging.getLogger(__name__) class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages polling for state changes from the device.""" - def __init__(self, hass: HomeAssistant, device: Device) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + ) -> None: """Initialize the data update coordinator.""" - DataUpdateCoordinator.__init__( - self, + super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}-{device.device_info.name}", update_interval=timedelta(seconds=UPDATE_INTERVAL), always_update=False, @@ -117,10 +122,11 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class DiscoveryService(Listener): """Discovery event handler for gree devices.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize discovery service.""" super().__init__() self.hass = hass + self.entry = entry self.discovery = Discovery(DISCOVERY_TIMEOUT) self.discovery.add_listener(self) @@ -144,7 +150,7 @@ class DiscoveryService(Listener): device.device_info.ip, device.device_info.port, ) - coordo = DeviceDataUpdateCoordinator(self.hass, device) + coordo = DeviceDataUpdateCoordinator(self.hass, self.entry, device) self.hass.data[DOMAIN][COORDINATORS].append(coordo) await coordo.async_refresh() From 86e44fc1cf22c20a579e623fd234a943a8205fbb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:24 +0100 Subject: [PATCH 309/410] Explicitly pass in the config_entry in govee_light_local coordinator (#137843) explicitly pass in the config_entry in coordinator --- homeassistant/components/govee_light_local/__init__.py | 2 +- homeassistant/components/govee_light_local/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index 44dbc825665..ee04dd81088 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Set up Govee light local from a config entry.""" - coordinator = GoveeLocalApiCoordinator(hass=hass) + coordinator = GoveeLocalApiCoordinator(hass, entry) async def await_cleanup(): cleanup_complete: asyncio.Event = coordinator.cleanup() diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index 240313a34b8..ecbed0c4f65 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -26,11 +26,16 @@ type GoveeLocalConfigEntry = ConfigEntry[GoveeLocalApiCoordinator] class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Govee light local coordinator.""" - def __init__(self, hass: HomeAssistant) -> None: + config_entry: GoveeLocalConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry + ) -> None: """Initialize my coordinator.""" super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name="GoveeLightLocalApi", update_interval=SCAN_INTERVAL, ) From f8ac48fc7896c669d07e9ed6064ab336a37a594c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:37 +0100 Subject: [PATCH 310/410] Explicitly pass in the config_entry in flo coordinator (#137819) explicitly pass in the config_entry in coordinator --- homeassistant/components/flo/__init__.py | 4 +++- homeassistant/components/flo/coordinator.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index b619df91d59..6a497f5140d 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -37,7 +37,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Flo user information with locations: %s", user_info) hass.data[DOMAIN][entry.entry_id]["devices"] = devices = [ - FloDeviceDataUpdateCoordinator(hass, client, location["id"], device["id"]) + FloDeviceDataUpdateCoordinator( + hass, entry, client, location["id"], device["id"] + ) for location in user_info["locations"] for device in location["devices"] ] diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index d0dd38bd490..f5dc34a50cd 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -10,6 +10,7 @@ from aioflo.api import API from aioflo.errors import RequestError from orjson import JSONDecodeError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -20,10 +21,16 @@ from .const import DOMAIN as FLO_DOMAIN, LOGGER class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): """Flo device object.""" + config_entry: ConfigEntry _failure_count: int = 0 def __init__( - self, hass: HomeAssistant, api_client: API, location_id: str, device_id: str + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api_client: API, + location_id: str, + device_id: str, ) -> None: """Initialize the device.""" self.hass: HomeAssistant = hass @@ -36,6 +43,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"{FLO_DOMAIN}-{device_id}", update_interval=timedelta(seconds=60), ) From 0b1afc68b028f1c9f198fc9ded6fc9b18a6f254b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:50 +0100 Subject: [PATCH 311/410] Explicitly pass in the config_entry in flume coordinator (#137822) explicitly pass in the config_entry in coordinator --- homeassistant/components/flume/__init__.py | 6 ++--- .../components/flume/binary_sensor.py | 2 +- homeassistant/components/flume/coordinator.py | 27 ++++++++++++++++--- homeassistant/components/flume/sensor.py | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 24ed41b21c1..d229665ca62 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -7,7 +7,7 @@ from requests import Session from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -41,7 +41,7 @@ LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( def _setup_entry( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FlumeConfigEntry ) -> tuple[FlumeAuth, FlumeDeviceList, Session]: """Config entry set up in executor.""" config = entry.data @@ -79,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FlumeConfigEntry) -> boo _setup_entry, hass, entry ) notification_coordinator = FlumeNotificationDataUpdateCoordinator( - hass=hass, auth=flume_auth + hass=hass, config_entry=entry, auth=flume_auth ) entry.runtime_data = FlumeRuntimeData( diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 67cb71c5767..cb0add90443 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -80,7 +80,7 @@ async def async_setup_entry( ] = [] connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( - hass=hass, flume_devices=flume_devices + hass=hass, config_entry=config_entry, flume_devices=flume_devices ) notification_coordinator = flume_domain_data.notifications_coordinator flume_devices = get_valid_flume_devices(flume_devices) diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index fc76600cad4..1dabf5726b2 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -38,10 +38,18 @@ type FlumeConfigEntry = ConfigEntry[FlumeRuntimeData] class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for an individual flume device.""" - def __init__(self, hass: HomeAssistant, flume_device: FlumeData) -> None: + config_entry: FlumeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: FlumeConfigEntry, + flume_device: FlumeData, + ) -> None: """Initialize the Coordinator.""" super().__init__( hass, + config_entry=config_entry, name=DOMAIN, logger=_LOGGER, update_interval=DEVICE_SCAN_INTERVAL, @@ -65,10 +73,18 @@ class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): """Date update coordinator to read connected status from Devices endpoint.""" - def __init__(self, hass: HomeAssistant, flume_devices: FlumeDeviceList) -> None: + config_entry: FlumeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: FlumeConfigEntry, + flume_devices: FlumeDeviceList, + ) -> None: """Initialize the Coordinator.""" super().__init__( hass, + config_entry=config_entry, name=DOMAIN, logger=_LOGGER, update_interval=DEVICE_CONNECTION_SCAN_INTERVAL, @@ -96,10 +112,15 @@ class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for flume notifications.""" - def __init__(self, hass: HomeAssistant, auth: FlumeAuth) -> None: + config_entry: FlumeConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: FlumeConfigEntry, auth: FlumeAuth + ) -> None: """Initialize the Coordinator.""" super().__init__( hass, + config_entry=config_entry, name=DOMAIN, logger=_LOGGER, update_interval=NOTIFICATION_SCAN_INTERVAL, diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 6c7cc0ab37d..aea0aa60093 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -132,7 +132,7 @@ async def async_setup_entry( flume_device = flume_datas[device_id] coordinator = FlumeDeviceDataUpdateCoordinator( - hass=hass, flume_device=flume_device + hass=hass, config_entry=config_entry, flume_device=flume_device ) flume_entity_list.extend( From 5e99b061260e8f3496ce9f97e0885f1b79806166 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:03 +0100 Subject: [PATCH 312/410] Explicitly pass in the config_entry in forecast_solar coordinator (#137824) explicitly pass in the config_entry in coordinator --- .../components/forecast_solar/__init__.py | 17 ++++++++++------- .../components/forecast_solar/coordinator.py | 15 +++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 00be13f1235..171341f7226 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -12,14 +11,14 @@ from .const import ( CONF_DAMPING_MORNING, CONF_MODULES_POWER, ) -from .coordinator import ForecastSolarDataUpdateCoordinator +from .coordinator import ForecastSolarConfigEntry, ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> bool: """Migrate old config entry.""" if entry.version == 1: @@ -53,11 +52,15 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index c9c062a0c88..efed954e490 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -23,15 +23,16 @@ from .const import ( LOGGER, ) +type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] + class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): """The Forecast.Solar Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: ForecastSolarConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None: """Initialize the Forecast.Solar coordinator.""" - self.config_entry = entry # Our option flow may cause it to be an empty string, # this if statement is here to catch that. @@ -61,7 +62,13 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): if api_key is not None: update_interval = timedelta(minutes=30) - 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) -> Estimate: """Fetch Forecast.Solar estimates.""" From 2634f0aba0d459e4d542b3e8189c542952956a9c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:16 +0100 Subject: [PATCH 313/410] Explicitly pass in the config_entry in atag coordinator init (#137716) explicitly pass in the config_entry in atag coordinator init --- homeassistant/components/atag/coordinator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/atag/coordinator.py b/homeassistant/components/atag/coordinator.py index 6d542471384..f590bc1dc6a 100644 --- a/homeassistant/components/atag/coordinator.py +++ b/homeassistant/components/atag/coordinator.py @@ -19,17 +19,22 @@ type AtagConfigEntry = ConfigEntry[AtagDataUpdateCoordinator] class AtagDataUpdateCoordinator(DataUpdateCoordinator[None]): """Atag data update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: AtagConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: AtagConfigEntry) -> None: """Initialize Atag coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Atag", update_interval=timedelta(seconds=60), ) self.atag = AtagOne( - session=async_get_clientsession(hass), **entry.data, device=entry.unique_id + session=async_get_clientsession(hass), + **config_entry.data, + device=config_entry.unique_id, ) async def _async_update_data(self) -> None: From 39d6aaf294b1c9c936151d33686ccad2e5ed82bb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:32 +0100 Subject: [PATCH 314/410] Explicitly pass in the config_entry in gogogate2 coordinator (#137837) explicitly pass in the config_entry in coordinator --- homeassistant/components/gogogate2/common.py | 1 + homeassistant/components/gogogate2/coordinator.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 52b1788c23e..8506414ca33 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -62,6 +62,7 @@ def get_data_update_coordinator( config_entry_data[DATA_UPDATE_COORDINATOR] = DeviceDataUpdateCoordinator( hass, + config_entry, _LOGGER, api, # Name of the data. For logging purposes. diff --git a/homeassistant/components/gogogate2/coordinator.py b/homeassistant/components/gogogate2/coordinator.py index 7c15e8b1c32..c2e7cc47b46 100644 --- a/homeassistant/components/gogogate2/coordinator.py +++ b/homeassistant/components/gogogate2/coordinator.py @@ -8,6 +8,7 @@ import logging from ismartgate import AbstractGateApi, GogoGate2InfoResponse, ISmartGateInfoResponse +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -18,9 +19,12 @@ class DeviceDataUpdateCoordinator( ): """Manages polling for state changes from the device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, api: AbstractGateApi, *, @@ -33,10 +37,10 @@ class DeviceDataUpdateCoordinator( request_refresh_debouncer: Debouncer | None = None, ) -> None: """Initialize the data update coordinator.""" - DataUpdateCoordinator.__init__( - self, + super().__init__( hass, logger, + config_entry=config_entry, name=name, update_interval=update_interval, update_method=update_method, From 9bdd8d04c5fe40338bbc78ec14ff9b4508f8d7de Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:49 +0100 Subject: [PATCH 315/410] Explicitly pass in the config_entry in goalzero coordinator (#137836) explicitly pass in the config_entry in coordinator --- homeassistant/components/goalzero/__init__.py | 2 +- homeassistant/components/goalzero/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 6698d1efc99..4a34927a585 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoalZeroConfigEntry) -> except exceptions.ConnectError as ex: raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex - entry.runtime_data = GoalZeroDataUpdateCoordinator(hass, api) + entry.runtime_data = GoalZeroDataUpdateCoordinator(hass, entry, api) await entry.runtime_data.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/goalzero/coordinator.py b/homeassistant/components/goalzero/coordinator.py index 3c7cd967482..97a7f537759 100644 --- a/homeassistant/components/goalzero/coordinator.py +++ b/homeassistant/components/goalzero/coordinator.py @@ -18,11 +18,14 @@ class GoalZeroDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: GoalZeroConfigEntry - def __init__(self, hass: HomeAssistant, api: Yeti) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: GoalZeroConfigEntry, api: Yeti + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) From 0efdceef277191cd9fd560eff75503f7fbe843fc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:17:06 +0100 Subject: [PATCH 316/410] Explicitly pass in the config_entry in glances coordinator (#137835) explicitly pass in the config_entry in coordinator --- homeassistant/components/glances/__init__.py | 5 +---- homeassistant/components/glances/coordinator.py | 10 +++++++--- homeassistant/components/glances/sensor.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 9d09e63606e..d7b645d9e11 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -10,7 +10,6 @@ from glances_api.exceptions import ( GlancesApiNoDataAvailable, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -29,15 +28,13 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.httpx_client import get_async_client -from .coordinator import GlancesDataUpdateCoordinator +from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -type GlancesConfigEntry = ConfigEntry[GlancesDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: GlancesConfigEntry diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8882b097ba9..28cf40aae6e 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -17,21 +17,25 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) +type GlancesConfigEntry = ConfigEntry[GlancesDataUpdateCoordinator] + class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get the latest data from Glances api.""" - config_entry: ConfigEntry + config_entry: GlancesConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Glances) -> None: + def __init__( + self, hass: HomeAssistant, entry: GlancesConfigEntry, api: Glances + ) -> None: """Initialize the Glances data.""" self.hass = hass - self.config_entry = entry self.host: str = entry.data[CONF_HOST] self.api = api super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{DOMAIN} - {self.host}", update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 0741926296e..61d88b744bf 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -22,8 +22,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GlancesConfigEntry, GlancesDataUpdateCoordinator from .const import CPU_ICON, DOMAIN +from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) From 13f6f045f58105c93a474f2dca6ba9077b9dcf56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:17:25 +0100 Subject: [PATCH 317/410] Explicitly pass in the config_entry in github coordinator (#137834) explicitly pass in the config_entry in coordinator --- homeassistant/components/github/__init__.py | 11 ++++------- homeassistant/components/github/coordinator.py | 7 +++++++ homeassistant/components/github/diagnostics.py | 5 +++-- homeassistant/components/github/sensor.py | 3 +-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index 74575e38e09..dea2acf4f1b 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from aiogithubapi import GitHubAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -14,14 +13,11 @@ from homeassistant.helpers.aiohttp_client import ( ) from .const import CONF_REPOSITORIES, DOMAIN, LOGGER -from .coordinator import GitHubDataUpdateCoordinator +from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]] - - async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool: """Set up GitHub from a config entry.""" client = GitHubAPI( @@ -36,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo for repository in repositories: coordinator = GitHubDataUpdateCoordinator( hass=hass, + config_entry=entry, client=client, repository=repository, ) @@ -57,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo @callback def async_cleanup_device_registry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GithubConfigEntry, ) -> None: """Remove entries form device registry if we no longer track the repository.""" device_registry = dr.async_get(hass) @@ -92,6 +89,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/github/coordinator.py b/homeassistant/components/github/coordinator.py index e73e02932e9..adeda1fd88a 100644 --- a/homeassistant/components/github/coordinator.py +++ b/homeassistant/components/github/coordinator.py @@ -13,6 +13,7 @@ from aiogithubapi import ( GitHubResponseModel, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -98,13 +99,18 @@ query ($owner: String!, $repository: String!) { } """ +type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]] + class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for the GitHub integration.""" + config_entry: GithubConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GithubConfigEntry, client: GitHubAPI, repository: str, ) -> None: @@ -118,6 +124,7 @@ class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=repository, update_interval=FALLBACK_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index 8d2d496a813..41fef9406a4 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -6,7 +6,6 @@ from typing import Any from aiogithubapi import GitHubAPI, GitHubException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import ( @@ -14,10 +13,12 @@ from homeassistant.helpers.aiohttp_client import ( async_get_clientsession, ) +from .coordinator import GithubConfigEntry + async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GithubConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data = {"options": {**config_entry.options}} diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 614ebe254c4..a7ecb4ec8da 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -18,9 +18,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GithubConfigEntry from .const import DOMAIN -from .coordinator import GitHubDataUpdateCoordinator +from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) From 239408aa5ddb8206549aabd5e3c7feab0499ae0e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:17:42 +0100 Subject: [PATCH 318/410] Explicitly pass in the config_entry in garages_amsterdam coordinator (#137829) explicitly pass in the config_entry in coordinator --- homeassistant/components/garages_amsterdam/__init__.py | 10 +++++----- .../components/garages_amsterdam/binary_sensor.py | 6 ++++-- .../components/garages_amsterdam/coordinator.py | 7 +++++++ homeassistant/components/garages_amsterdam/sensor.py | 6 ++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 99d751cfcc8..854e41f2d89 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -4,24 +4,24 @@ from __future__ import annotations from odp_amsterdam import ODPAmsterdam -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from .coordinator import ( + GaragesAmsterdamConfigEntry, + GaragesAmsterdamDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry ) -> bool: """Set up Garages Amsterdam from a config entry.""" client = ODPAmsterdam(session=async_get_clientsession(hass)) - coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, client) + coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index b93b43e1173..cf4b29f0af8 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -15,8 +15,10 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GaragesAmsterdamConfigEntry -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from .coordinator import ( + GaragesAmsterdamConfigEntry, + GaragesAmsterdamDataUpdateCoordinator, +) from .entity import GaragesAmsterdamEntity diff --git a/homeassistant/components/garages_amsterdam/coordinator.py b/homeassistant/components/garages_amsterdam/coordinator.py index 3d06aba79e2..74f2361980d 100644 --- a/homeassistant/components/garages_amsterdam/coordinator.py +++ b/homeassistant/components/garages_amsterdam/coordinator.py @@ -4,24 +4,31 @@ from __future__ import annotations from odp_amsterdam import Garage, ODPAmsterdam, VehicleType +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator] + class GaragesAmsterdamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Garage]]): """Class to manage fetching Garages Amsterdam data from single endpoint.""" + config_entry: GaragesAmsterdamConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GaragesAmsterdamConfigEntry, client: ODPAmsterdam, ) -> None: """Initialize global Garages Amsterdam data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index b562fff841a..8c16260c58b 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -16,8 +16,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import GaragesAmsterdamConfigEntry -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from .coordinator import ( + GaragesAmsterdamConfigEntry, + GaragesAmsterdamDataUpdateCoordinator, +) from .entity import GaragesAmsterdamEntity From 2f0e6615690798d4f126ad88967d38df65e6c11e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:19:06 +0100 Subject: [PATCH 319/410] Explicitly pass in the config_entry in apsystems coordinator init (#137708) explicitly pass in the config_entry in apsystems coordinator init --- .../components/apsystems/__init__.py | 20 +++--------------- .../components/apsystems/binary_sensor.py | 3 +-- .../components/apsystems/coordinator.py | 21 ++++++++++++++++++- homeassistant/components/apsystems/entity.py | 2 +- homeassistant/components/apsystems/number.py | 2 +- homeassistant/components/apsystems/sensor.py | 3 +-- homeassistant/components/apsystems/switch.py | 2 +- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index c437f5584db..cdc4563b92d 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -2,16 +2,13 @@ from __future__ import annotations -from dataclasses import dataclass - from APsystemsEZ1 import APsystemsEZ1M -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT -from .coordinator import ApSystemsDataCoordinator +from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -21,17 +18,6 @@ PLATFORMS: list[Platform] = [ ] -@dataclass -class ApSystemsData: - """Store runtime data.""" - - coordinator: ApSystemsDataCoordinator - device_id: str - - -type ApSystemsConfigEntry = ConfigEntry[ApSystemsData] - - async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool: """Set up this integration using UI.""" api = APsystemsEZ1M( @@ -40,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> timeout=8, enable_debounce=True, ) - coordinator = ApSystemsDataCoordinator(hass, api) + coordinator = ApSystemsDataCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() assert entry.unique_id entry.runtime_data = ApSystemsData( @@ -51,6 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/apsystems/binary_sensor.py b/homeassistant/components/apsystems/binary_sensor.py index 9e361ca883e..863a50ca455 100644 --- a/homeassistant/components/apsystems/binary_sensor.py +++ b/homeassistant/components/apsystems/binary_sensor.py @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ApSystemsConfigEntry, ApSystemsData -from .coordinator import ApSystemsDataCoordinator +from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator from .entity import ApSystemsEntity diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index 2535c66c4ac..ca423055176 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -12,6 +12,7 @@ from APsystemsEZ1 import ( ReturnOutputData, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,16 +27,34 @@ class ApSystemsSensorData: alarm_info: ReturnAlarmInfo +@dataclass +class ApSystemsData: + """Store runtime data.""" + + coordinator: ApSystemsDataCoordinator + device_id: str + + +type ApSystemsConfigEntry = ConfigEntry[ApSystemsData] + + class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): """Coordinator used for all sensors.""" + config_entry: ApSystemsConfigEntry device_version: str - def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ApSystemsConfigEntry, + api: APsystemsEZ1M, + ) -> None: """Initialize my coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="APSystems Data", update_interval=timedelta(seconds=12), ) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 7770b451680..9ba7d046b60 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -5,8 +5,8 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from . import ApSystemsData from .const import DOMAIN +from .coordinator import ApSystemsData class ApSystemsEntity(Entity): diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index b5ed60a7754..f7bdc7c2711 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType -from . import ApSystemsConfigEntry, ApSystemsData +from .coordinator import ApSystemsConfigEntry, ApSystemsData from .entity import ApSystemsEntity diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py index f87bc0f3f26..673dba05acc 100644 --- a/homeassistant/components/apsystems/sensor.py +++ b/homeassistant/components/apsystems/sensor.py @@ -19,8 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ApSystemsConfigEntry, ApSystemsData -from .coordinator import ApSystemsDataCoordinator +from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator from .entity import ApSystemsEntity diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index 73914845445..2d3b0cfd08f 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ApSystemsConfigEntry, ApSystemsData +from .coordinator import ApSystemsConfigEntry, ApSystemsData from .entity import ApSystemsEntity From 0a842d171b47d46024ea21962b3b83b8336a92be Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:20:30 +0100 Subject: [PATCH 320/410] Limit habitica ConfigEntrySelect to integration domain (#137767) --- homeassistant/components/habitica/services.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index ed4a6444ea2..2537655dbfb 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -77,7 +77,7 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( SERVICE_CAST_SKILL_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_SKILL): cv.string, vol.Optional(ATTR_TASK): cv.string, } @@ -85,12 +85,12 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema( SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), } ) SERVICE_SCORE_TASK_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_DIRECTION): cv.string, } @@ -98,7 +98,7 @@ SERVICE_SCORE_TASK_SCHEMA = vol.Schema( SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_ITEM): cv.string, vol.Required(ATTR_TARGET): cv.string, } @@ -106,7 +106,7 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( SERVICE_GET_TASKS_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Optional(ATTR_TYPE): vol.All( cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))] ), From 2d72b814d6f89ab316fd89b162c00a7f76518406 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:21:13 +0100 Subject: [PATCH 321/410] Explicitly pass in the config_entry in cert_expiry coordinator init (#137728) explicitly pass in the config_entry in cert_expiry coordinator init --- homeassistant/components/cert_expiry/__init__.py | 9 +++------ .../components/cert_expiry/coordinator.py | 14 +++++++++++++- homeassistant/components/cert_expiry/sensor.py | 3 +-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index bc6ae29ee8e..adf1e0e981c 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -2,24 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from .coordinator import CertExpiryDataUpdateCoordinator +from .coordinator import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type CertExpiryConfigEntry = ConfigEntry[CertExpiryDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) -> bool: """Load the saved entities.""" host: str = entry.data[CONF_HOST] port: int = entry.data[CONF_PORT] - coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) + coordinator = CertExpiryDataUpdateCoordinator(hass, entry, host, port) entry.runtime_data = coordinator @@ -34,6 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py index 80c91f1d890..644e3ee3d00 100644 --- a/homeassistant/components/cert_expiry/coordinator.py +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,11 +15,21 @@ from .helper import get_cert_expiry_timestamp _LOGGER = logging.getLogger(__name__) +type CertExpiryConfigEntry = ConfigEntry[CertExpiryDataUpdateCoordinator] + class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): """Class to manage fetching Cert Expiry data from single endpoint.""" - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + config_entry: CertExpiryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: CertExpiryConfigEntry, + host: str, + port: int, + ) -> None: """Initialize global Cert Expiry data updater.""" self.host = host self.port = port @@ -31,6 +42,7 @@ class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=timedelta(hours=12), always_update=False, diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 4fd0846f0f3..a875e664fdd 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -9,9 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import CertExpiryConfigEntry from .const import DOMAIN -from .coordinator import CertExpiryDataUpdateCoordinator +from .coordinator import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator from .entity import CertExpiryEntity From 88a2b2ab90c72675d2065e5dc5a3c11ca4c3e658 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:22:00 +0100 Subject: [PATCH 322/410] Explicitly pass in the config_entry in deluge coordinator (#137733) explicitly pass in the config_entry in coordinator --- homeassistant/components/deluge/__init__.py | 4 +--- homeassistant/components/deluge/coordinator.py | 8 ++++---- homeassistant/components/deluge/sensor.py | 3 +-- homeassistant/components/deluge/switch.py | 3 +-- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 9b07ae9c875..f9972570df3 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -7,7 +7,6 @@ from ssl import SSLError from deluge_client.client import DelugeRPCClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -19,12 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_WEB_PORT -from .coordinator import DelugeDataUpdateCoordinator +from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bool: diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py index 7f4bf9e884e..c5836243b9d 100644 --- a/homeassistant/components/deluge/coordinator.py +++ b/homeassistant/components/deluge/coordinator.py @@ -4,10 +4,11 @@ from __future__ import annotations from datetime import timedelta from ssl import SSLError -from typing import TYPE_CHECKING, Any +from typing import Any from deluge_client.client import DelugeRPCClient, FailedToReconnectException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -15,8 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER, DelugeGetSessionStatusKeys -if TYPE_CHECKING: - from . import DelugeConfigEntry +type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator] class DelugeDataUpdateCoordinator( @@ -33,11 +33,11 @@ class DelugeDataUpdateCoordinator( super().__init__( hass=hass, logger=LOGGER, + config_entry=entry, name=entry.title, update_interval=timedelta(seconds=30), ) self.api = api - self.config_entry = entry async def _async_update_data(self) -> dict[Platform, dict[str, Any]]: """Get the latest data from Deluge and updates the state.""" diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 5ebf3d01eeb..24d5ce9ec61 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DelugeConfigEntry from .const import DelugeGetSessionStatusKeys, DelugeSensorType -from .coordinator import DelugeDataUpdateCoordinator +from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator from .entity import DelugeEntity diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index d81f02eee29..1ec0cd7a7df 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -9,8 +9,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DelugeConfigEntry -from .coordinator import DelugeDataUpdateCoordinator +from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator from .entity import DelugeEntity From 1c7bf9f589006478d66691c944820315b7820ffb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:22:37 +0100 Subject: [PATCH 323/410] Explicitly pass in the config_entry in enigma2 coordinator (#137739) explicitly pass in the config_entry in coordinator --- homeassistant/components/enigma2/__init__.py | 7 ++----- homeassistant/components/enigma2/coordinator.py | 11 +++++++++-- homeassistant/components/enigma2/media_player.py | 3 +-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py index da78f3dac5c..16295c7f228 100644 --- a/homeassistant/components/enigma2/__init__.py +++ b/homeassistant/components/enigma2/__init__.py @@ -1,12 +1,9 @@ """Support for Enigma2 devices.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import Enigma2UpdateCoordinator - -type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator] +from .coordinator import Enigma2ConfigEntry, Enigma2UpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -22,6 +19,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py index d5bbf2c0ce5..9710d7f547f 100644 --- a/homeassistant/components/enigma2/coordinator.py +++ b/homeassistant/components/enigma2/coordinator.py @@ -30,18 +30,25 @@ from .const import CONF_SOURCE_BOUQUET, DOMAIN LOGGER = logging.getLogger(__package__) +type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator] + class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): """The Enigma2 data update coordinator.""" + config_entry: Enigma2ConfigEntry device: OpenWebIfDevice unique_id: str | None - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: Enigma2ConfigEntry) -> None: """Initialize the Enigma2 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, ) base_url = URL.build( diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 1012997ff7f..9a2a4564d1c 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -18,8 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Enigma2ConfigEntry -from .coordinator import Enigma2UpdateCoordinator +from .coordinator import Enigma2ConfigEntry, Enigma2UpdateCoordinator ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_DESCRIPTION = "media_description" From fae2c94c7407e245e4cdcff75c26b437da9547c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 8 Feb 2025 13:42:43 +0100 Subject: [PATCH 324/410] Add snapshot tests for setup of LetPot platforms (#137756) --- tests/components/letpot/__init__.py | 19 +- tests/components/letpot/conftest.py | 18 +- .../letpot/snapshots/test_switch.ambr | 185 ++++++++++++++++++ .../letpot/snapshots/test_time.ambr | 93 +++++++++ tests/components/letpot/test_switch.py | 22 ++- tests/components/letpot/test_time.py | 22 ++- 6 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 tests/components/letpot/snapshots/test_switch.ambr create mode 100644 tests/components/letpot/snapshots/test_time.ambr diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index ac552f907d4..d4570ce44be 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -2,7 +2,12 @@ import datetime -from letpot.models import AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus +from letpot.models import ( + AuthenticationInfo, + LetPotDeviceErrors, + LetPotDeviceStatus, + TemperatureUnit, +) from homeassistant.core import HomeAssistant @@ -26,17 +31,21 @@ AUTHENTICATION = AuthenticationInfo( ) STATUS = LetPotDeviceStatus( - errors=LetPotDeviceErrors(low_water=False), + errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, light_mode=1, - light_schedule_end=datetime.time(12, 10), - light_schedule_start=datetime.time(12, 0), + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), online=True, plant_days=1, pump_mode=1, pump_nutrient=None, pump_status=0, - raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], + raw=[], # Not used by integration, and it requires a real device to get system_on=True, system_sound=False, + temperature_unit=TemperatureUnit.CELSIUS, + temperature_value=18, + water_mode=1, + water_level=100, ) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 3e948ad0ac2..454d4e235db 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import LetPotDevice +from letpot.models import DeviceFeature, LetPotDevice import pytest from homeassistant.components.letpot.const import ( @@ -47,9 +47,9 @@ def mock_client() -> Generator[AsyncMock]: client.refresh_token.return_value = AUTHENTICATION client.get_devices.return_value = [ LetPotDevice( - serial_number="LPH21ABCD", + serial_number="LPH63ABCD", name="Garden", - device_type="LPH21", + device_type="LPH63", is_online=True, is_remote=False, ) @@ -65,8 +65,16 @@ def mock_device_client() -> Generator[AsyncMock]: autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_model_code = "LPH21" - device_client.device_model_name = "LetPot Air" + device_client.device_features = ( + DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.PUMP_STATUS + | DeviceFeature.TEMPERATURE + | DeviceFeature.WATER_LEVEL + ) + device_client.device_model_code = "LPH63" + device_client.device_model_name = "LetPot Max" subscribe_callbacks: list[Callable] = [] diff --git a/tests/components/letpot/snapshots/test_switch.ambr b/tests/components/letpot/snapshots/test_switch.ambr new file mode 100644 index 00000000000..28ca9603760 --- /dev/null +++ b/tests/components/letpot/snapshots/test_switch.ambr @@ -0,0 +1,185 @@ +# serializer version: 1 +# name: test_all_entities[switch.garden_alarm_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_alarm_sound', + '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': 'Alarm sound', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_sound', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_alarm_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Alarm sound', + }), + 'context': , + 'entity_id': 'switch.garden_alarm_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.garden_auto_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_auto_mode', + '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': 'Auto mode', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_auto_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_auto_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Auto mode', + }), + 'context': , + 'entity_id': 'switch.garden_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.garden_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_power', + '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': 'Power', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Power', + }), + 'context': , + 'entity_id': 'switch.garden_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.garden_pump_cycling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_pump_cycling', + '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': 'Pump cycling', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_cycling', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_pump_cycling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Pump cycling', + }), + 'context': , + 'entity_id': 'switch.garden_pump_cycling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/letpot/snapshots/test_time.ambr b/tests/components/letpot/snapshots/test_time.ambr new file mode 100644 index 00000000000..66f6648c202 --- /dev/null +++ b/tests/components/letpot/snapshots/test_time.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_all_entities[time.garden_light_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.garden_light_off', + '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': 'Light off', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_schedule_end', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.garden_light_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light off', + }), + 'context': , + 'entity_id': 'time.garden_light_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18:00:00', + }) +# --- +# name: test_all_entities[time.garden_light_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.garden_light_on', + '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': 'Light on', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_schedule_start', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.garden_light_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light on', + }), + 'context': , + 'entity_id': 'time.garden_light_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index d51721c3348..b166d551adb 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -1,17 +1,35 @@ """Test switch entities for the LetPot integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest +from syrupy import SnapshotAssertion from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index 44a03e565c0..82e69979067 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -1,18 +1,36 @@ """Test time entities for the LetPot integration.""" from datetime import time -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest +from syrupy import SnapshotAssertion from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test time entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.TIME]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( From 9e8f2e81bdd90d399394b3258f65c42adf46eccc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:43:28 +0100 Subject: [PATCH 325/410] Explicitly pass in the config_entry in enphase flexit_bacnet coordinator (#137814) explicitly pass in the config_entry in coordinator --- homeassistant/components/flexit_bacnet/__init__.py | 6 ++---- homeassistant/components/flexit_bacnet/coordinator.py | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 6b42310d181..b0ebc5a40fd 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -21,9 +21,7 @@ PLATFORMS: list[Platform] = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flexit Nordic (BACnet) from a config entry.""" - device_id = entry.data[CONF_DEVICE_ID] - - coordinator = FlexitCoordinator(hass, device_id) + coordinator = FlexitCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py index 79f3b6a05ad..f723117c9ef 100644 --- a/homeassistant/components/flexit_bacnet/coordinator.py +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -23,12 +23,13 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device_id: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, - name=f"{DOMAIN}_{device_id}", + config_entry=config_entry, + name=f"{DOMAIN}_{config_entry.data[CONF_DEVICE_ID]}", update_interval=timedelta(seconds=60), ) From ad9d43bc50d74dbe0eade315f9fb8699190c6c01 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:46:59 +0100 Subject: [PATCH 326/410] Explicitly pass in the config_entry in duke_energy coordinator (#137741) explicitly pass in the config_entry in coordinator --- homeassistant/components/duke_energy/__init__.py | 2 +- homeassistant/components/duke_energy/coordinator.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py index 6eacc15880f..bfa89d81c69 100644 --- a/homeassistant/components/duke_energy/__init__.py +++ b/homeassistant/components/duke_energy/__init__.py @@ -10,7 +10,7 @@ from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool: """Set up Duke Energy from a config entry.""" - coordinator = DukeEnergyCoordinator(hass, entry.data) + coordinator = DukeEnergyCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 2b0ae46b405..12a2f5fd6ae 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta import logging -from types import MappingProxyType from typing import Any, cast from aiodukeenergy import DukeEnergy @@ -37,22 +36,21 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): config_entry: DukeEnergyConfigEntry def __init__( - self, - hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + self, hass: HomeAssistant, config_entry: DukeEnergyConfigEntry ) -> None: """Initialize the data handler.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Duke Energy", # Data is updated daily on Duke Energy. # Refresh every 12h to be at most 12h behind. update_interval=timedelta(hours=12), ) self.api = DukeEnergy( - entry_data[CONF_USERNAME], - entry_data[CONF_PASSWORD], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) self._statistic_ids: set = set() From 074d384d27f862d1b41a338d3f5163aecc2a79b2 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:51:00 +0100 Subject: [PATCH 327/410] Bump PyViCare to 2.42.0 (#137804) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 766cf22cb94..489d4accb8a 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.41.0"] + "requirements": ["PyViCare==2.42.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe7ea7fc5ce..ddc55a51ef1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.41.0 +PyViCare==2.42.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a686bbd0633..9df8712d286 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.41.0 +PyViCare==2.42.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From ce66b476533ad9ac9c4f02f24a3bb517a3df9828 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 8 Feb 2025 04:52:28 -0800 Subject: [PATCH 328/410] Update fitbit quality scale for runtime-data (#137785) --- homeassistant/components/fitbit/quality_scale.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/quality_scale.yaml b/homeassistant/components/fitbit/quality_scale.yaml index abf127cdb98..04ed2817a07 100644 --- a/homeassistant/components/fitbit/quality_scale.yaml +++ b/homeassistant/components/fitbit/quality_scale.yaml @@ -20,11 +20,7 @@ rules: comment: Fitbit is a polling integration that does use async events. entity-unique-id: done has-entity-name: done - runtime-data: - status: todo - comment: | - The integration uses `hass.data` for data associated with a configuration - entry and needs to be updated to use `runtime_data`. + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done From de79fb26db8967e3d6ce4f64dea336db2a024422 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sat, 8 Feb 2025 12:52:59 +0000 Subject: [PATCH 329/410] Bump ohmepy to 1.2.9 (#137695) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 602c53ced7b..100967f819f 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.8"] + "requirements": ["ohme==1.2.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ddc55a51ef1..c63c75271c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1547,7 +1547,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.8 +ohme==1.2.9 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9df8712d286..4e9bc32edf4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1295,7 +1295,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.8 +ohme==1.2.9 # homeassistant.components.ollama ollama==0.4.7 From 78fce5112d639286f7d4c298dab8ac60fa1ff1cd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:00:50 +0100 Subject: [PATCH 330/410] Explicitly pass in the config_entry in guardian coordinator (#137848) explicitly pass in the config_entry in coordinator --- homeassistant/components/guardian/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py index 849cec8063c..500b7c10784 100644 --- a/homeassistant/components/guardian/coordinator.py +++ b/homeassistant/components/guardian/coordinator.py @@ -42,6 +42,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=entry, name=f"{valve_controller_uid}_{api_name}", update_interval=DEFAULT_UPDATE_INTERVAL, ) @@ -50,7 +51,6 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._api_lock = api_lock self._client = client - self.config_entry = entry self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( self.config_entry.entry_id ) From daccb3e9b36e80e42ffd49120273debe69985851 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:06:50 +0100 Subject: [PATCH 331/410] Explicitly pass in the config_entry in fitbit coordinator (#137808) explicitly pass in the config_entry in coordinator --- homeassistant/components/fitbit/__init__.py | 8 ++------ homeassistant/components/fitbit/coordinator.py | 17 +++++++++++++++-- homeassistant/components/fitbit/sensor.py | 3 +-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 0c4a37198d6..f2378797d8d 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1,6 +1,5 @@ """The fitbit component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -8,16 +7,13 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import FitbitScope -from .coordinator import FitbitData, FitbitDeviceCoordinator +from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import config_from_entry_data PLATFORMS: list[Platform] = [Platform.SENSOR] -type FitbitConfigEntry = ConfigEntry[FitbitData] - - async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: """Set up fitbit from a config entry.""" implementation = ( @@ -39,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bo fitbit_config = config_from_entry_data(entry.data) coordinator: FitbitDeviceCoordinator | None = None if fitbit_config.is_allowed_resource(FitbitScope.DEVICE, "devices/battery"): - coordinator = FitbitDeviceCoordinator(hass, fitbit_api) + coordinator = FitbitDeviceCoordinator(hass, entry, fitbit_api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = FitbitData(api=fitbit_api, device_coordinator=coordinator) diff --git a/homeassistant/components/fitbit/coordinator.py b/homeassistant/components/fitbit/coordinator.py index 2126129d261..867723419dd 100644 --- a/homeassistant/components/fitbit/coordinator.py +++ b/homeassistant/components/fitbit/coordinator.py @@ -6,6 +6,7 @@ import datetime import logging from typing import Final +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,13 +20,25 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) TIMEOUT = 10 +type FitbitConfigEntry = ConfigEntry[FitbitData] + class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]): """Coordinator for fetching fitbit devices from the API.""" - def __init__(self, hass: HomeAssistant, api: FitbitApi) -> None: + config_entry: FitbitConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: FitbitConfigEntry, api: FitbitApi + ) -> None: """Initialize FitbitDeviceCoordinator.""" - super().__init__(hass, _LOGGER, name="Fitbit", update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Fitbit", + update_interval=UPDATE_INTERVAL, + ) self._api = api async def _async_update_data(self) -> dict[str, FitbitDevice]: diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 4ccbea97a66..bbb3da46e52 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -28,10 +28,9 @@ 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 FitbitDeviceCoordinator +from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, config_from_entry_data From 37239fca447a1b6cc222a2e85a5cad642e857b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Sat, 8 Feb 2025 14:13:58 +0100 Subject: [PATCH 332/410] Update flexit_bacnet dependecy 2.2.1 -> 2.2.3 (#137730) --- homeassistant/components/flexit_bacnet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json index 40390162ce6..6f6b094c950 100644 --- a/homeassistant/components/flexit_bacnet/manifest.json +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["flexit_bacnet==2.2.1"] + "requirements": ["flexit_bacnet==2.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c63c75271c4..0fdf048bc63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,7 +930,7 @@ fixerio==1.0.0a0 fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet -flexit_bacnet==2.2.1 +flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e9bc32edf4..053acf5bd86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,7 +789,7 @@ fivem-api==0.1.2 fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet -flexit_bacnet==2.2.1 +flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 From 60d966f06f2cf8bb8f33c9dd7bd6324c716a55a1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:20:30 +0100 Subject: [PATCH 333/410] Limit transmission ConfigEntrySelect to integration domain (#137769) --- homeassistant/components/transmission/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 1a8ffdea0c2..578488dad1a 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -78,7 +78,9 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector( + {"integration": DOMAIN} + ), } ) From f3aeca5a71007bceb731f3dd42cf062039ba2fbf Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Feb 2025 05:53:15 -0800 Subject: [PATCH 334/410] Call backup listener during setup in Google Drive (#137789) --- homeassistant/components/google_drive/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index af93956931a..b30bc2ae1f6 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from google_drive_api.exceptions import GoogleDriveApiError from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,6 +49,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) except GoogleDriveApiError as err: raise ConfigEntryNotReady from err + _async_notify_backup_listeners_soon(hass) + return True @@ -56,10 +58,15 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" - hass.loop.call_soon(_notify_backup_listeners, hass) + _async_notify_backup_listeners_soon(hass) return True -def _notify_backup_listeners(hass: HomeAssistant) -> None: +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) From 780e6aa073950cd1148bddf13878fba44c68ab12 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:11 +0100 Subject: [PATCH 335/410] Explicitly pass in the config_entry in awair coordinator init (#137717) explicitly pass in the config_entry in awair coordinator init --- homeassistant/components/awair/coordinator.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py index 78f0d9d65f2..62725693522 100644 --- a/homeassistant/components/awair/coordinator.py +++ b/homeassistant/components/awair/coordinator.py @@ -40,17 +40,24 @@ class AwairResult: class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): """Define a wrapper class to update Awair data.""" + config_entry: AwairConfigEntry + def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AwairConfigEntry, update_interval: timedelta | None, ) -> None: """Set up the AwairDataUpdateCoordinator class.""" - self._config_entry = config_entry self.title = config_entry.title - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: """Fetch latest air quality data.""" @@ -64,7 +71,10 @@ class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): """Define a wrapper class to update Awair data from Cloud API.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + self, + hass: HomeAssistant, + config_entry: AwairConfigEntry, + session: ClientSession, ) -> None: """Set up the AwairCloudDataUpdateCoordinator class.""" access_token = config_entry.data[CONF_ACCESS_TOKEN] @@ -95,7 +105,10 @@ class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): _device: AwairLocalDevice | None = None def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + self, + hass: HomeAssistant, + config_entry: AwairConfigEntry, + session: ClientSession, ) -> None: """Set up the AwairLocalDataUpdateCoordinator class.""" self._awair = AwairLocal( From de1a503284ce1f3c1c43471bad330ddddd159400 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:20 +0100 Subject: [PATCH 336/410] Explicitly pass in the config_entry in enphase envoy coordinator (#137806) explicitly pass in the config_entry in coordinator --- homeassistant/components/enphase_envoy/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 8eb2b32ac7b..b8cda03a451 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -55,6 +55,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry_data[CONF_NAME], update_interval=SCAN_INTERVAL, always_update=False, From b22830260cff4f90daad76ea187957be368a6832 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:32 +0100 Subject: [PATCH 337/410] Explicitly pass in the config_entry in filesize coordinator (#137807) explicitly pass in the config_entry in coordinator --- homeassistant/components/filesize/__init__.py | 10 +++------- homeassistant/components/filesize/coordinator.py | 10 ++++++++-- homeassistant/components/filesize/sensor.py | 3 +-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 602eac1f24d..b10125de67c 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -2,19 +2,15 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILE_PATH from homeassistant.core import HomeAssistant from .const import PLATFORMS -from .coordinator import FileSizeCoordinator - -type FileSizeConfigEntry = ConfigEntry[FileSizeCoordinator] +from .coordinator import FileSizeConfigEntry, FileSizeCoordinator async def async_setup_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> bool: """Set up from a config entry.""" - coordinator = FileSizeCoordinator(hass, entry.data[CONF_FILE_PATH]) + coordinator = FileSizeCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -22,6 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 0c2a0277434..87f59f1a53e 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -7,6 +7,8 @@ import logging import os import pathlib +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -15,22 +17,26 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type FileSizeConfigEntry = ConfigEntry[FileSizeCoordinator] + class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): """Filesize coordinator.""" + config_entry: FileSizeConfigEntry path: pathlib.Path - def __init__(self, hass: HomeAssistant, unresolved_path: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: FileSizeConfigEntry) -> None: """Initialize filesize coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), always_update=False, ) - self._unresolved_path = unresolved_path + self._unresolved_path = self.config_entry.data[CONF_FILE_PATH] def _get_full_path(self) -> pathlib.Path: """Check if path is valid, allowed and return full path.""" diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 2eb170af99d..bd8b9b6c462 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -17,9 +17,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FileSizeConfigEntry from .const import DOMAIN -from .coordinator import FileSizeCoordinator +from .coordinator import FileSizeConfigEntry, FileSizeCoordinator _LOGGER = logging.getLogger(__name__) From d97ef67620c06bc2667cfe447306ef667f9109e2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:46 +0100 Subject: [PATCH 338/410] Explicitly pass in the config_entry in gios coordinator (#137832) explicitly pass in the config_entry in coordinator --- homeassistant/components/gios/__init__.py | 15 ++--------- homeassistant/components/gios/coordinator.py | 27 ++++++++++++++++++-- homeassistant/components/gios/diagnostics.py | 2 +- homeassistant/components/gios/sensor.py | 3 +-- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index b5a0e9d5371..c76efbcf361 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -2,32 +2,21 @@ from __future__ import annotations -from dataclasses import dataclass import logging from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID, DOMAIN -from .coordinator import GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosData, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type GiosConfigEntry = ConfigEntry[GiosData] - - -@dataclass -class GiosData: - """Data for GIOS integration.""" - - coordinator: GiosDataUpdateCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool: """Set up GIOS as config entry.""" @@ -48,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool websession = async_get_clientsession(hass) - coordinator = GiosDataUpdateCoordinator(hass, websession, station_id) + coordinator = GiosDataUpdateCoordinator(hass, entry, websession, station_id) await coordinator.async_config_entry_first_refresh() entry.runtime_data = GiosData(coordinator) diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index 17b4b89174f..be4b41ca6ee 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging from aiohttp import ClientSession @@ -11,6 +12,7 @@ from gios import Gios from gios.exceptions import GiosError from gios.model import GiosSensors +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,17 +20,38 @@ from .const import API_TIMEOUT, DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type GiosConfigEntry = ConfigEntry[GiosData] + + +@dataclass +class GiosData: + """Data for GIOS integration.""" + + coordinator: GiosDataUpdateCoordinator + class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): """Define an object to hold GIOS data.""" + config_entry: GiosConfigEntry + def __init__( - self, hass: HomeAssistant, session: ClientSession, station_id: int + self, + hass: HomeAssistant, + config_entry: GiosConfigEntry, + session: ClientSession, + station_id: int, ) -> None: """Class to manage fetching GIOS data API.""" self.gios = Gios(station_id, session) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) async def _async_update_data(self) -> GiosSensors: """Update data via library.""" diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index a94a95254de..7e938d5ac6b 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import GiosConfigEntry +from .coordinator import GiosConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 69e198d34df..096ea838a41 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GiosConfigEntry from .const import ( ATTR_AQI, ATTR_C6H6, @@ -38,7 +37,7 @@ from .const import ( MANUFACTURER, URL, ) -from .coordinator import GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) From c9ffeb80077998d10c6a459cc07d6e589fbf7ac2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:10 +0100 Subject: [PATCH 339/410] Explicitly pass in the config_entry in flipr coordinator (#137818) explicitly pass in the config_entry in coordinator --- homeassistant/components/flipr/__init__.py | 23 +++++++------------ .../components/flipr/binary_sensor.py | 2 +- homeassistant/components/flipr/coordinator.py | 21 +++++++++++++++-- homeassistant/components/flipr/select.py | 2 +- homeassistant/components/flipr/sensor.py | 2 +- homeassistant/components/flipr/switch.py | 2 +- 6 files changed, 31 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 99bddb5a0d0..81e61f2554a 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,7 +1,6 @@ """The Flipr integration.""" from collections import Counter -from dataclasses import dataclass import logging from flipr_api import FliprAPIRestClient @@ -13,24 +12,18 @@ from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .coordinator import FliprDataUpdateCoordinator, FliprHubDataUpdateCoordinator +from .coordinator import ( + FliprConfigEntry, + FliprData, + FliprDataUpdateCoordinator, + FliprHubDataUpdateCoordinator, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -@dataclass -class FliprData: - """The Flipr data class.""" - - flipr_coordinators: list[FliprDataUpdateCoordinator] - hub_coordinators: list[FliprHubDataUpdateCoordinator] - - -type FliprConfigEntry = ConfigEntry[FliprData] - - async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool: """Set up flipr from a config entry.""" @@ -50,13 +43,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> boo flipr_coordinators = [] for flipr_id in ids["flipr"]: - flipr_coordinator = FliprDataUpdateCoordinator(hass, client, flipr_id) + flipr_coordinator = FliprDataUpdateCoordinator(hass, entry, client, flipr_id) await flipr_coordinator.async_config_entry_first_refresh() flipr_coordinators.append(flipr_coordinator) hub_coordinators = [] for hub_id in ids["hub"]: - hub_coordinator = FliprHubDataUpdateCoordinator(hass, client, hub_id) + hub_coordinator = FliprHubDataUpdateCoordinator(hass, entry, client, hub_id) await hub_coordinator.async_config_entry_first_refresh() hub_coordinators.append(hub_coordinator) diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index cc6a9d36abc..07357b81af0 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py index 12fd174fe7d..0d86b43711a 100644 --- a/homeassistant/components/flipr/coordinator.py +++ b/homeassistant/components/flipr/coordinator.py @@ -1,5 +1,6 @@ """DataUpdateCoordinator for flipr integration.""" +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -14,13 +15,28 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +@dataclass +class FliprData: + """The Flipr data class.""" + + flipr_coordinators: list["FliprDataUpdateCoordinator"] + hub_coordinators: list["FliprHubDataUpdateCoordinator"] + + +type FliprConfigEntry = ConfigEntry[FliprData] + + class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Parent class to hold Flipr and Hub data retrieval.""" - config_entry: ConfigEntry + config_entry: FliprConfigEntry def __init__( - self, hass: HomeAssistant, client: FliprAPIRestClient, flipr_or_hub_id: str + self, + hass: HomeAssistant, + config_entry: FliprConfigEntry, + client: FliprAPIRestClient, + flipr_or_hub_id: str, ) -> None: """Initialize.""" self.device_id = flipr_or_hub_id @@ -29,6 +45,7 @@ class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Flipr or Hub data measure for {self.device_id}", update_interval=timedelta(minutes=15), ) diff --git a/homeassistant/components/flipr/select.py b/homeassistant/components/flipr/select.py index b8a8f0db60a..79515be6ed4 100644 --- a/homeassistant/components/flipr/select.py +++ b/homeassistant/components/flipr/select.py @@ -6,7 +6,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index ba863718182..2594186f24a 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTempe from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( diff --git a/homeassistant/components/flipr/switch.py b/homeassistant/components/flipr/switch.py index 65e729ec280..03df7f34d12 100644 --- a/homeassistant/components/flipr/switch.py +++ b/homeassistant/components/flipr/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity _LOGGER = logging.getLogger(__name__) From 846797a3942b4ee91d398858a95c57e773e900e1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:21 +0100 Subject: [PATCH 340/410] Explicitly pass in the config_entry in google_tasks coordinator (#137842) explicitly pass in the config_entry in coordinator --- homeassistant/components/google_tasks/__init__.py | 4 ++-- homeassistant/components/google_tasks/coordinator.py | 7 +++++++ homeassistant/components/google_tasks/todo.py | 3 +-- homeassistant/components/google_tasks/types.py | 7 ------- 4 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 homeassistant/components/google_tasks/types.py diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 45ad1777aa0..2d570854ad4 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -13,9 +13,8 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN -from .coordinator import TaskUpdateCoordinator +from .coordinator import GoogleTasksConfigEntry, TaskUpdateCoordinator from .exceptions import GoogleTasksApiError -from .types import GoogleTasksConfigEntry __all__ = [ "DOMAIN", @@ -52,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) coordinators = [ TaskUpdateCoordinator( hass, + entry, auth, task_list["id"], task_list["title"], diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py index a06faf00a91..b61fe1c30db 100644 --- a/homeassistant/components/google_tasks/coordinator.py +++ b/homeassistant/components/google_tasks/coordinator.py @@ -5,6 +5,7 @@ import datetime import logging from typing import Any, Final +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,13 +16,18 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) TIMEOUT = 10 +type GoogleTasksConfigEntry = ConfigEntry[list[TaskUpdateCoordinator]] + class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Coordinator for fetching Google Tasks for a Task List form the API.""" + config_entry: GoogleTasksConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GoogleTasksConfigEntry, api: AsyncConfigEntryAuth, task_list_id: str, task_list_title: str, @@ -30,6 +36,7 @@ class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Google Tasks {task_list_id}", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 1df5e5fc2e9..6d1969d9a8a 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -16,8 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .coordinator import TaskUpdateCoordinator -from .types import GoogleTasksConfigEntry +from .coordinator import GoogleTasksConfigEntry, TaskUpdateCoordinator PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/google_tasks/types.py b/homeassistant/components/google_tasks/types.py deleted file mode 100644 index 21500d11eb8..00000000000 --- a/homeassistant/components/google_tasks/types.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Types for the Google Tasks integration.""" - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import TaskUpdateCoordinator - -type GoogleTasksConfigEntry = ConfigEntry[list[TaskUpdateCoordinator]] From e8e4fb296b9fa9ed9ae9f34789b7f0566de7eb92 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:42 +0100 Subject: [PATCH 341/410] Explicitly pass in the config_entry in flux_led coordinator (#137823) explicitly pass in the config_entry in coordinator --- homeassistant/components/flux_led/coordinator.py | 4 +++- homeassistant/components/flux_led/entity.py | 4 +++- homeassistant/components/flux_led/select.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index a473387a513..a879d894bcc 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -24,17 +24,19 @@ REQUEST_REFRESH_DELAY: Final = 2.0 class FluxLedUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific flux_led device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: ConfigEntry ) -> None: """Initialize DataUpdateCoordinator to gather data for specific device.""" self.device = device self.title = entry.title - self.entry = entry self.force_next_update = False super().__init__( hass, _LOGGER, + config_entry=entry, name=self.device.ipaddr, update_interval=timedelta(seconds=10), # We don't want an immediate refresh since the device diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index bcf7bfff9ed..f9b87dbb8c1 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -89,7 +89,9 @@ class FluxEntity(CoordinatorEntity[FluxLedUpdateCoordinator]): self._attr_unique_id = f"{base_unique_id}_{key}" else: self._attr_unique_id = base_unique_id - self._attr_device_info = _async_device_info(self._device, coordinator.entry) + self._attr_device_info = _async_device_info( + self._device, coordinator.config_entry + ) async def _async_ensure_device_on(self) -> None: """Turn the device on if it needs to be turned on before a command.""" diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index 3809e73147a..33329ebb3f3 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -141,7 +141,7 @@ class FluxICTypeSelect(FluxConfigSelect): async def async_select_option(self, option: str) -> None: """Change the ic type.""" await self._device.async_set_device_config(ic_type=option) - await _async_delayed_reload(self.hass, self.coordinator.entry) + await _async_delayed_reload(self.hass, self.coordinator.config_entry) class FluxWiringsSelect(FluxConfigSelect): @@ -184,7 +184,7 @@ class FluxOperatingModesSelect(FluxConfigSelect): async def async_select_option(self, option: str) -> None: """Change the ic type.""" await self._device.async_set_device_config(operating_mode=option) - await _async_delayed_reload(self.hass, self.coordinator.entry) + await _async_delayed_reload(self.hass, self.coordinator.config_entry) class FluxRemoteConfigSelect(FluxConfigSelect): From 04c20b9534256f9bf398040e531b808313c30597 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:52 +0100 Subject: [PATCH 342/410] Explicitly pass in the config_entry in fyta coordinator (#137828) explicitly pass in the config_entry in coordinator --- homeassistant/components/fyta/__init__.py | 6 ++---- homeassistant/components/fyta/binary_sensor.py | 2 +- homeassistant/components/fyta/coordinator.py | 12 +++++++----- homeassistant/components/fyta/diagnostics.py | 2 +- homeassistant/components/fyta/entity.py | 3 +-- homeassistant/components/fyta/image.py | 3 +-- homeassistant/components/fyta/sensor.py | 3 +-- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index ab4a74c627a..1b00afc9c80 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -7,7 +7,6 @@ import logging from fyta_cli.fyta_connector import FytaConnector -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_PASSWORD, @@ -19,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.dt import async_get_time_zone from .const import CONF_EXPIRATION -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,6 @@ PLATFORMS = [ Platform.IMAGE, Platform.SENSOR, ] -type FytaConfigEntry = ConfigEntry[FytaCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool: @@ -46,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool username, password, access_token, expiration, tz, async_get_clientsession(hass) ) - coordinator = FytaCoordinator(hass, fyta) + coordinator = FytaCoordinator(hass, entry, fyta) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py index bcef609d01a..66e5b2feeca 100644 --- a/homeassistant/components/fyta/binary_sensor.py +++ b/homeassistant/components/fyta/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FytaConfigEntry +from .coordinator import FytaConfigEntry from .entity import FytaPlantEntity diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index a0c42d449d5..012ed3b2af0 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING from fyta_cli.fyta_connector import FytaConnector from fyta_cli.fyta_exceptions import ( @@ -16,6 +15,7 @@ from fyta_cli.fyta_exceptions import ( ) from fyta_cli.fyta_models import Plant +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -24,22 +24,24 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_EXPIRATION, DOMAIN -if TYPE_CHECKING: - from . import FytaConfigEntry - _LOGGER = logging.getLogger(__name__) +type FytaConfigEntry = ConfigEntry[FytaCoordinator] + class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): """Fyta custom coordinator.""" config_entry: FytaConfigEntry - def __init__(self, hass: HomeAssistant, fyta: FytaConnector) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: FytaConfigEntry, fyta: FytaConnector + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="FYTA Coordinator", update_interval=timedelta(minutes=4), ) diff --git a/homeassistant/components/fyta/diagnostics.py b/homeassistant/components/fyta/diagnostics.py index d02f8cacfa3..d6bda70d754 100644 --- a/homeassistant/components/fyta/diagnostics.py +++ b/homeassistant/components/fyta/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FytaConfigEntry +from .coordinator import FytaConfigEntry TO_REDACT = [ CONF_PASSWORD, diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py index 0d0ec533c44..02cd73c54f9 100644 --- a/homeassistant/components/fyta/entity.py +++ b/homeassistant/components/fyta/entity.py @@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FytaConfigEntry from .const import DOMAIN -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]): diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index f03df969dcc..4a0b32f605b 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FytaConfigEntry -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 254e4522819..66c96ab697b 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -25,8 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import FytaConfigEntry -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity From bc07598f47644ebb63b81c0e876ce1880691b25d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:55 +0100 Subject: [PATCH 343/410] Explicitly pass in the config_entry in fujitsu_fglair coordinator (#137826) explicitly pass in the config_entry in coordinator --- homeassistant/components/fujitsu_fglair/__init__.py | 7 ++----- homeassistant/components/fujitsu_fglair/climate.py | 3 +-- homeassistant/components/fujitsu_fglair/coordinator.py | 10 +++++++++- homeassistant/components/fujitsu_fglair/sensor.py | 3 +-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index 547545e4feb..699356a2e75 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -7,18 +7,15 @@ from contextlib import suppress from ayla_iot_unofficial import new_ayla_api from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU -from .coordinator import FGLairCoordinator +from .coordinator import FGLairConfigEntry, FGLairCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] -type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool: """Set up Fujitsu HVAC (based on Ayla IOT) from a config entry.""" @@ -33,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bo timeout=API_TIMEOUT, ) - coordinator = FGLairCoordinator(hass, api) + coordinator = FGLairCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index c0f5ab7dce4..5df6573e638 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -27,8 +27,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemper from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FGLairConfigEntry -from .coordinator import FGLairCoordinator +from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity HA_TO_FUJI_FAN = { diff --git a/homeassistant/components/fujitsu_fglair/coordinator.py b/homeassistant/components/fujitsu_fglair/coordinator.py index d98464e4751..8c66548973b 100644 --- a/homeassistant/components/fujitsu_fglair/coordinator.py +++ b/homeassistant/components/fujitsu_fglair/coordinator.py @@ -5,6 +5,7 @@ import logging from ayla_iot_unofficial import AylaApi, AylaAuthError from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -13,15 +14,22 @@ from .const import API_REFRESH _LOGGER = logging.getLogger(__name__) +type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] + class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]): """Coordinator for Fujitsu HVAC integration.""" - def __init__(self, hass: HomeAssistant, api: AylaApi) -> None: + config_entry: FGLairConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: FGLairConfigEntry, api: AylaApi + ) -> None: """Initialize coordinator for Fujitsu HVAC integration.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Fujitsu HVAC data", update_interval=API_REFRESH, ) diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index 1426e2349ea..e095a566dcb 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -11,8 +11,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .climate import FGLairConfigEntry -from .coordinator import FGLairCoordinator +from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity From 1179278d5053c40a597c4d4bad7dec65adff36a1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:12 +0100 Subject: [PATCH 344/410] Explicitly pass in the config_entry in fully_kiosk coordinator (#137827) explicitly pass in the config_entry in coordinator --- homeassistant/components/fully_kiosk/__init__.py | 5 +---- homeassistant/components/fully_kiosk/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index 074ec3feaa0..772e7f79242 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -1,17 +1,14 @@ """The Fully Kiosk Browser integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import FullyKioskDataUpdateCoordinator +from .coordinator import FullyKioskConfigEntry, FullyKioskDataUpdateCoordinator from .services import async_setup_services -type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator] - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 405f0746437..dc3640020be 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -14,11 +14,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_PORT, LOGGER, UPDATE_INTERVAL +type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator] + class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Fully Kiosk Browser data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: FullyKioskConfigEntry + + def __init__(self, hass: HomeAssistant, entry: FullyKioskConfigEntry) -> None: """Initialize.""" self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( @@ -32,6 +36,7 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, LOGGER, + config_entry=entry, name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) From 7c9312b3cfe54fea60e7fbc1762cd2f661f6c3c1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:34 +0100 Subject: [PATCH 345/410] Explicitly pass in the config_entry in goodwe coordinator (#137838) explicitly pass in the config_entry in coordinator --- homeassistant/components/goodwe/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index a8ee7df6337..914ba3155b4 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -19,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Gather data for the energy device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -29,6 +31,7 @@ class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry.title, update_interval=SCAN_INTERVAL, ) From 70d5685b729b23ae155c42c66687dcc94fd8ee6e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:43 +0100 Subject: [PATCH 346/410] Explicitly pass in the config_entry in geocaching coordinator (#137831) explicitly pass in the config_entry in coordinator --- homeassistant/components/geocaching/coordinator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geocaching/coordinator.py b/homeassistant/components/geocaching/coordinator.py index 8f56cd9d846..41b59d049af 100644 --- a/homeassistant/components/geocaching/coordinator.py +++ b/homeassistant/components/geocaching/coordinator.py @@ -18,12 +18,13 @@ from .const import DOMAIN, ENVIRONMENT, LOGGER, UPDATE_INTERVAL class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): """Class to manage fetching Geocaching data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session ) -> None: """Initialize global Geocaching data updater.""" self.session = session - self.entry = entry async def async_token_refresh() -> str: await session.async_ensure_token_valid() @@ -39,7 +40,13 @@ class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): token_refresh_method=async_token_refresh, ) - 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) -> GeocachingStatus: try: From 9e091f7a73ff1a20bf2de17ed9cfed0eed869e49 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:53 +0100 Subject: [PATCH 347/410] Explicitly pass in the config_entry in google coordinator (#137839) explicitly pass in the config_entry in coordinator --- homeassistant/components/google/calendar.py | 2 ++ homeassistant/components/google/coordinator.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 5ac5dae616c..82208420b8c 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -266,6 +266,7 @@ async def async_setup_entry( if not entity_description.local_sync: coordinator = CalendarQueryUpdateCoordinator( hass, + config_entry, calendar_service, entity_description.name or entity_description.key, calendar_id, @@ -285,6 +286,7 @@ async def async_setup_entry( ) coordinator = CalendarSyncUpdateCoordinator( hass, + config_entry, sync, entity_description.name or entity_description.key, ) diff --git a/homeassistant/components/google/coordinator.py b/homeassistant/components/google/coordinator.py index 19198041c05..4a8a3d9f167 100644 --- a/homeassistant/components/google/coordinator.py +++ b/homeassistant/components/google/coordinator.py @@ -52,6 +52,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, sync: CalendarEventSyncManager, name: str, ) -> None: @@ -59,6 +60,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=MIN_TIME_BETWEEN_UPDATES, ) @@ -111,6 +113,7 @@ class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, calendar_service: GoogleCalendarService, name: str, calendar_id: str, @@ -120,6 +123,7 @@ class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=MIN_TIME_BETWEEN_UPDATES, ) From 11d5628da735aaf364337134501af16d9e60a9d0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:35:00 +0100 Subject: [PATCH 348/410] Explicitly pass in the config_entry in google_photos coordinator (#137840) explicitly pass in the config_entry in coordinator --- homeassistant/components/google_photos/__init__.py | 7 ++++--- .../components/google_photos/coordinator.py | 13 ++++++++++++- .../components/google_photos/media_source.py | 2 +- homeassistant/components/google_photos/services.py | 2 +- homeassistant/components/google_photos/types.py | 7 ------- 5 files changed, 18 insertions(+), 13 deletions(-) delete mode 100644 homeassistant/components/google_photos/types.py diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 2a7109d8189..40de02554ae 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -12,9 +12,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import api from .const import DOMAIN -from .coordinator import GooglePhotosUpdateCoordinator +from .coordinator import GooglePhotosConfigEntry, GooglePhotosUpdateCoordinator from .services import async_register_services -from .types import GooglePhotosConfigEntry __all__ = [ "DOMAIN", @@ -43,7 +42,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - coordinator = GooglePhotosUpdateCoordinator(hass, GooglePhotosLibraryApi(auth)) + coordinator = GooglePhotosUpdateCoordinator( + hass, entry, GooglePhotosLibraryApi(auth) + ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/google_photos/coordinator.py b/homeassistant/components/google_photos/coordinator.py index 3ba5a8124d6..215d40d7864 100644 --- a/homeassistant/components/google_photos/coordinator.py +++ b/homeassistant/components/google_photos/coordinator.py @@ -15,6 +15,7 @@ from google_photos_library_api.api import GooglePhotosLibraryApi from google_photos_library_api.exceptions import GooglePhotosApiError from google_photos_library_api.model import Album, NewAlbum +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = datetime.timedelta(hours=24) ALBUM_PAGE_SIZE = 50 +type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator] + class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): """Coordinator for fetching Google Photos albums. @@ -30,11 +33,19 @@ class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): The `data` object is a dict from Album ID to Album title. """ - def __init__(self, hass: HomeAssistant, client: GooglePhotosLibraryApi) -> None: + config_entry: GooglePhotosConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: GooglePhotosConfigEntry, + client: GooglePhotosLibraryApi, + ) -> None: """Initialize TaskUpdateCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Google Photos", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 7ee81b51bc0..c0a87e46fbc 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -20,8 +20,8 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant -from . import GooglePhotosConfigEntry from .const import DOMAIN, READ_SCOPE +from .coordinator import GooglePhotosConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 22d3cc7deb0..8042df8f811 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from .const import DOMAIN, UPLOAD_SCOPE -from .types import GooglePhotosConfigEntry +from .coordinator import GooglePhotosConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" CONF_ALBUM = "album" diff --git a/homeassistant/components/google_photos/types.py b/homeassistant/components/google_photos/types.py deleted file mode 100644 index 4f4cc1845e4..00000000000 --- a/homeassistant/components/google_photos/types.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Google Photos types.""" - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import GooglePhotosUpdateCoordinator - -type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator] From dbf403a2d32dcba055c078a0d5159c7346a28e86 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 15:39:36 +0100 Subject: [PATCH 349/410] Make action descriptions in adguard consistent, remove "true/false" (#137799) * Make action descriptions in adguard consistent Change the remaining two to also use third-person singular for consistency. * Remove "true" and "false" for UI-friendly wording --- homeassistant/components/adguard/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index 5b6a5a546f7..44f4a388e6e 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -79,7 +79,7 @@ "services": { "add_url": { "name": "Add URL", - "description": "Add a new filter subscription to AdGuard Home.", + "description": "Adds a new filter subscription to AdGuard Home.", "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", @@ -123,11 +123,11 @@ }, "refresh": { "name": "Refresh", - "description": "Refresh all filter subscriptions in AdGuard Home.", + "description": "Refreshes all filter subscriptions in AdGuard Home.", "fields": { "force": { "name": "Force", - "description": "Force update (bypasses AdGuard Home throttling). \"true\" to force, or \"false\" to omit for a regular refresh." + "description": "Force update (bypasses AdGuard Home throttling), omit for a regular refresh." } } } From 97cde3770229c6a9e3f83576b7fd81d2779cab82 Mon Sep 17 00:00:00 2001 From: Patrick <14628713+patman15@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:40:59 +0100 Subject: [PATCH 350/410] Fix manufacturer_id matching for 0 (#137802) fix manufacturer_id matching for 0 --- homeassistant/components/bluetooth/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 6307d3ca93b..c37fa4615f6 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -411,7 +411,7 @@ def ble_device_matches( ) and service_data_uuid not in service_info.service_data: return False - if manufacturer_id := matcher.get(MANUFACTURER_ID): + if (manufacturer_id := matcher.get(MANUFACTURER_ID)) is not None: if manufacturer_id not in service_info.manufacturer_data: return False From 21dd6fa53d7b40214681b5fbd59a7f088bb68b28 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:43:03 +0100 Subject: [PATCH 351/410] Explicitly pass in the config_entry in flick_electric coordinator (#137816) * explicitly pass in the config_entry in coordinator * Apply suggestions from code review Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/flick_electric/__init__.py | 9 ++++----- .../components/flick_electric/coordinator.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py index 3ffddee1c7d..ad772c06b2e 100644 --- a/homeassistant/components/flick_electric/__init__.py +++ b/homeassistant/components/flick_electric/__init__.py @@ -9,7 +9,6 @@ from pyflick import FlickAPI from pyflick.authentication import SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -35,9 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> boo """Set up Flick Electric from a config entry.""" auth = HassFlickAuth(hass, entry) - coordinator = FlickElectricDataCoordinator( - hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF] - ) + coordinator = FlickElectricDataCoordinator(hass, entry, FlickAPI(auth)) await coordinator.async_config_entry_first_refresh() @@ -53,7 +50,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bo return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: FlickConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", diff --git a/homeassistant/components/flick_electric/coordinator.py b/homeassistant/components/flick_electric/coordinator.py index 474efc5297d..114b364635f 100644 --- a/homeassistant/components/flick_electric/coordinator.py +++ b/homeassistant/components/flick_electric/coordinator.py @@ -13,6 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import CONF_SUPPLY_NODE_REF + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) @@ -23,17 +25,23 @@ type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator] class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]): """Coordinator for flick power price.""" + config_entry: FlickConfigEntry + def __init__( - self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str + self, + hass: HomeAssistant, + config_entry: FlickConfigEntry, + api: FlickAPI, ) -> None: """Initialize FlickElectricDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Flick Electric", update_interval=SCAN_INTERVAL, ) - self.supply_node_ref = supply_node_ref + self.supply_node_ref = config_entry.data[CONF_SUPPLY_NODE_REF] self._api = api async def _async_update_data(self) -> FlickPrice: From a542a2e0212cdb2ff22983afd34b8ae1f8e2b61c Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 8 Feb 2025 14:45:48 +0000 Subject: [PATCH 352/410] Refactor evohome for major bump of client to 1.0.2 (#135436) * working test_init * update fixtures to be compliant with new schema * test_storage is now working * all tests passing * bump client to 1.0.1b0 * test commit (working tests) * use only id (not e.g. zoneId), use StrEnums * mypy, lint * remove deprecated module * remove waffle * improve typing of asserts * broker is now coordinator * WIP - test failing * rename class * remove unneeded async_dispatcher_send() * restore missing code * harden test * bugfix failing test * don't capture blind except * shrink log messages * doctweak * rationalize asserts * remove unneeded listerner * refactor setup * bump client to 1.0.2b0 * bump client to 1.0.2b1 * refactor extended state attrs * pass UpdateFailed to _async_refresh() * Update homeassistant/components/evohome/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/evohome/entity.py Co-authored-by: Joost Lekkerkerker * not even lint * undo not even lint * remove unused logger * restore old namespace for e_s_a * minimize diff * doctweak * remove unused method * lint * DUC now working * restore old camelCase keynames * tweak * small tweak to _handle_coordinator_update() * Update homeassistant/components/evohome/coordinator.py Co-authored-by: Joost Lekkerkerker * add test of coordinator * bump client to 1.0.2 --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/evohome/__init__.py | 232 ++++----------- homeassistant/components/evohome/climate.py | 272 +++++++++--------- homeassistant/components/evohome/const.py | 23 -- .../components/evohome/coordinator.py | 250 +++++++++------- homeassistant/components/evohome/entity.py | 203 ++++++------- homeassistant/components/evohome/helpers.py | 110 ------- .../components/evohome/manifest.json | 4 +- homeassistant/components/evohome/storage.py | 118 ++++++++ .../components/evohome/water_heater.py | 91 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/evohome/conftest.py | 125 ++++---- .../fixtures/h032585/user_locations.json | 1 + .../fixtures/h099625/user_locations.json | 1 + .../evohome/snapshots/test_climate.ambr | 258 +++++++++-------- .../evohome/snapshots/test_water_heater.ambr | 32 +-- tests/components/evohome/test_climate.py | 84 +++--- tests/components/evohome/test_coordinator.py | 55 ++++ tests/components/evohome/test_init.py | 203 ++++++++----- tests/components/evohome/test_storage.py | 58 ++-- tests/components/evohome/test_water_heater.py | 30 +- 21 files changed, 1065 insertions(+), 1089 deletions(-) delete mode 100644 homeassistant/components/evohome/helpers.py create mode 100644 homeassistant/components/evohome/storage.py create mode 100644 tests/components/evohome/test_coordinator.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 97f7c2db54d..e322e266b8a 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -2,22 +2,24 @@ Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and others. + +Note that the API used by this integration's client does not support cooling. """ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, Final +from typing import Final -import evohomeasync as ev1 -from evohomeasync.schema import SZ_SESSION_ID -import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_AUTO_WITH_RESET, - SZ_CAN_BE_TEMPORARY, - SZ_SYSTEM_MODE, - SZ_TIMING_MODE, +import evohomeasync as ec1 +import evohomeasync2 as ec2 +from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE +from evohomeasync2.schemas.const import ( + S2_DURATION as SZ_DURATION, + S2_PERIOD as SZ_PERIOD, + SystemMode as EvoSystemMode, ) import voluptuous as vol @@ -34,14 +36,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( - ACCESS_TOKEN, - ACCESS_TOKEN_EXPIRES, ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, @@ -49,16 +47,12 @@ from .const import ( ATTR_ZONE_TEMP, CONF_LOCATION_IDX, DOMAIN, - REFRESH_TOKEN, SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_MINIMUM, - STORAGE_KEY, - STORAGE_VER, - USER_DATA, EvoService, ) -from .coordinator import EvoBroker -from .helpers import dt_aware_to_naive, dt_local_to_aware, handle_evo_exception +from .coordinator import EvoDataUpdateCoordinator +from .storage import TokenManager _LOGGER = logging.getLogger(__name__) @@ -96,177 +90,69 @@ SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( } ) +EVOHOME_KEY: HassKey[EvoData] = HassKey(DOMAIN) -class EvoSession: - """Class for evohome client instantiation & authentication.""" - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the evohome broker and its data structure.""" +@dataclass +class EvoData: + """Dataclass for storing evohome data.""" - self.hass = hass - - self._session = async_get_clientsession(hass) - self._store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) - - # the main client, which uses the newer API - self.client_v2: evo.EvohomeClient | None = None - self._tokens: dict[str, Any] = {} - - # the older client can be used to obtain high-precision temps (only) - self.client_v1: ev1.EvohomeClient | None = None - self.session_id: str | None = None - - async def authenticate(self, username: str, password: str) -> None: - """Check the user credentials against the web API. - - Will raise evo.AuthenticationFailed if the credentials are invalid. - """ - - if ( - self.client_v2 is None - or username != self.client_v2.username - or password != self.client_v2.password - ): - await self._load_auth_tokens(username) - - client_v2 = evo.EvohomeClient( - username, - password, - **self._tokens, - session=self._session, - ) - - else: # force a re-authentication - client_v2 = self.client_v2 - client_v2._user_account = None # noqa: SLF001 - - await client_v2.login() - self.client_v2 = client_v2 # only set attr if authentication succeeded - - await self.save_auth_tokens() - - self.client_v1 = ev1.EvohomeClient( - username, - password, - session_id=self.session_id, - session=self._session, - ) - - async def _load_auth_tokens(self, username: str) -> None: - """Load access tokens and session_id from the store and validate them. - - Sets self._tokens and self._session_id to the latest values. - """ - - app_storage: dict[str, Any] = dict(await self._store.async_load() or {}) - - if app_storage.pop(CONF_USERNAME, None) != username: - # any tokens won't be valid, and store might be corrupt - await self._store.async_save({}) - - self.session_id = None - self._tokens = {} - - return - - # evohomeasync2 requires naive/local datetimes as strings - if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and ( - expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES]) - ): - app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires) - - user_data: dict[str, str] = app_storage.pop(USER_DATA, {}) or {} - - self.session_id = user_data.get(SZ_SESSION_ID) - self._tokens = app_storage - - async def save_auth_tokens(self) -> None: - """Save access tokens and session_id to the store. - - Sets self._tokens and self._session_id to the latest values. - """ - - if self.client_v2 is None: - await self._store.async_save({}) - return - - # evohomeasync2 uses naive/local datetimes - access_token_expires = dt_local_to_aware( - self.client_v2.access_token_expires # type: ignore[arg-type] - ) - - self._tokens = { - CONF_USERNAME: self.client_v2.username, - REFRESH_TOKEN: self.client_v2.refresh_token, - ACCESS_TOKEN: self.client_v2.access_token, - ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), - } - - self.session_id = self.client_v1.broker.session_id if self.client_v1 else None - - app_storage = self._tokens - if self.client_v1: - app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_id} - - await self._store.async_save(app_storage) + coordinator: EvoDataUpdateCoordinator + loc_idx: int + tcs: ec2.ControlSystem async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Evohome integration.""" - sess = EvoSession(hass) - - try: - await sess.authenticate( - config[DOMAIN][CONF_USERNAME], - config[DOMAIN][CONF_PASSWORD], - ) - - except (evo.AuthenticationFailed, evo.RequestFailed) as err: - handle_evo_exception(err) - return False - - finally: - config[DOMAIN][CONF_PASSWORD] = "REDACTED" - - broker = EvoBroker(sess) - - if not broker.validate_location( - config[DOMAIN][CONF_LOCATION_IDX], - ): - return False - - coordinator = DataUpdateCoordinator( + token_manager = TokenManager( + hass, + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + async_get_clientsession(hass), + ) + coordinator = EvoDataUpdateCoordinator( hass, _LOGGER, - config_entry=None, + ec2.EvohomeClient(token_manager), name=f"{DOMAIN}_coordinator", update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], - update_method=broker.async_update, + location_idx=config[DOMAIN][CONF_LOCATION_IDX], + client_v1=ec1.EvohomeClient(token_manager), ) + await coordinator.async_register_shutdown() + await coordinator.async_first_refresh() - hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator} + if not coordinator.last_update_success: + _LOGGER.error(f"Failed to fetch initial data: {coordinator.last_exception}") # noqa: G004 + return False - # without a listener, _schedule_refresh() won't be invoked by _async_refresh() - coordinator.async_add_listener(lambda: None) - await coordinator.async_refresh() # get initial state + assert coordinator.tcs is not None # mypy + + hass.data[EVOHOME_KEY] = EvoData( + coordinator=coordinator, + loc_idx=coordinator.loc_idx, + tcs=coordinator.tcs, + ) hass.async_create_task( async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) ) - if broker.tcs.hotwater: + if coordinator.tcs.hotwater: hass.async_create_task( async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) ) - setup_service_functions(hass, broker) + setup_service_functions(hass, coordinator) return True @callback -def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: +def setup_service_functions( + hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator +) -> None: """Set up the service handlers for the system/zone operating modes. Not all Honeywell TCC-compatible systems support all operating modes. In addition, @@ -279,13 +165,15 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: @verify_domain_control(hass, DOMAIN) async def force_refresh(call: ServiceCall) -> None: """Obtain the latest state data via the vendor's RESTful API.""" - await broker.async_update() + await coordinator.async_refresh() @verify_domain_control(hass, DOMAIN) async def set_system_mode(call: ServiceCall) -> None: """Set the system mode.""" + assert coordinator.tcs is not None # mypy + payload = { - "unique_id": broker.tcs.systemId, + "unique_id": coordinator.tcs.id, "service": call.service, "data": call.data, } @@ -313,17 +201,23 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: async_dispatcher_send(hass, DOMAIN, payload) + assert coordinator.tcs is not None # mypy + hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system - modes = broker.tcs.allowedSystemModes + modes = list(coordinator.tcs.allowed_system_modes) # Not all systems support "AutoWithReset": register this handler only if required - if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: + if any( + m[SZ_SYSTEM_MODE] + for m in modes + if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET + ): hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) system_mode_schemas = [] - modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] + modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET] # Permanent-only modes will use this schema perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] @@ -334,7 +228,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { @@ -348,7 +242,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: system_mode_schemas.append(schema) # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 64e7367bc32..8a455b300f8 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -4,20 +4,20 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_ACTIVE_FAULTS, +from evohomeasync2.const import ( SZ_SETPOINT_STATUS, - SZ_SYSTEM_ID, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE_STATUS, - SZ_UNTIL, - SZ_ZONE_ID, - ZoneModelType, - ZoneType, +) +from evohomeasync2.schemas.const import ( + SystemMode as EvoSystemMode, + ZoneMode as EvoZoneMode, + ZoneModelType as EvoZoneModelType, + ZoneType as EvoZoneType, ) from homeassistant.components.climate import ( @@ -30,67 +30,46 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util +from . import EVOHOME_KEY from .const import ( ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, ATTR_SYSTEM_MODE, ATTR_ZONE_TEMP, - DOMAIN, - EVO_AUTO, - EVO_AUTOECO, - EVO_AWAY, - EVO_CUSTOM, - EVO_DAYOFF, - EVO_FOLLOW, - EVO_HEATOFF, - EVO_PERMOVER, - EVO_RESET, - EVO_TEMPOVER, EvoService, ) -from .entity import EvoChild, EvoDevice - -if TYPE_CHECKING: - from . import EvoBroker - +from .coordinator import EvoDataUpdateCoordinator +from .entity import EvoChild, EvoEntity _LOGGER = logging.getLogger(__name__) -PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW +PRESET_RESET = "Reset" # reset all child zones to EvoZoneMode.FOLLOW_SCHEDULE PRESET_CUSTOM = "Custom" TCS_PRESET_TO_HA = { - EVO_AWAY: PRESET_AWAY, - EVO_CUSTOM: PRESET_CUSTOM, - EVO_AUTOECO: PRESET_ECO, - EVO_DAYOFF: PRESET_HOME, - EVO_RESET: PRESET_RESET, -} # EVO_AUTO: None, + EvoSystemMode.AWAY: PRESET_AWAY, + EvoSystemMode.CUSTOM: PRESET_CUSTOM, + EvoSystemMode.AUTO_WITH_ECO: PRESET_ECO, + EvoSystemMode.DAY_OFF: PRESET_HOME, + EvoSystemMode.AUTO_WITH_RESET: PRESET_RESET, +} # EvoSystemMode.AUTO: None, HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} EVO_PRESET_TO_HA = { - EVO_FOLLOW: PRESET_NONE, - EVO_TEMPOVER: "temporary", - EVO_PERMOVER: "permanent", + EvoZoneMode.FOLLOW_SCHEDULE: PRESET_NONE, + EvoZoneMode.TEMPORARY_OVERRIDE: "temporary", + EvoZoneMode.PERMANENT_OVERRIDE: "permanent", } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS] -STATE_ATTRS_ZONES = [ - SZ_ZONE_ID, - SZ_ACTIVE_FAULTS, - SZ_SETPOINT_STATUS, - SZ_TEMPERATURE_STATUS, -] - async def async_setup_platform( hass: HomeAssistant, @@ -102,32 +81,34 @@ async def async_setup_platform( if discovery_info is None: return - broker: EvoBroker = hass.data[DOMAIN]["broker"] + coordinator = hass.data[EVOHOME_KEY].coordinator + loc_idx = hass.data[EVOHOME_KEY].loc_idx + tcs = hass.data[EVOHOME_KEY].tcs _LOGGER.debug( "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", - broker.tcs.modelType, - broker.tcs.systemId, - broker.loc.name, - broker.loc_idx, + tcs.model, + tcs.id, + tcs.location.name, + loc_idx, ) - entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] + entities: list[EvoController | EvoZone] = [EvoController(coordinator, tcs)] - for zone in broker.tcs.zones.values(): + for zone in tcs.zones: if ( - zone.modelType == ZoneModelType.HEATING_ZONE - or zone.zoneType == ZoneType.THERMOSTAT + zone.model == EvoZoneModelType.HEATING_ZONE + or zone.type == EvoZoneType.THERMOSTAT ): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", - zone.zoneType, - zone.modelType, - zone.zoneId, + zone.type, + zone.model, + zone.id, zone.name, ) - new_entity = EvoZone(broker, zone) + new_entity = EvoZone(coordinator, zone) entities.append(new_entity) else: @@ -136,16 +117,19 @@ async def async_setup_platform( "Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, " "report as an issue if you feel this zone type should be supported" ), - zone.zoneType, - zone.modelType, - zone.zoneId, + zone.type, + zone.model, + zone.id, zone.name, ) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) + + for entity in entities: + await entity.update_attrs() -class EvoClimateEntity(EvoDevice, ClimateEntity): +class EvoClimateEntity(EvoEntity, ClimateEntity): """Base for any evohome-compatible climate entity (controller, zone).""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] @@ -157,25 +141,29 @@ class EvoZone(EvoChild, EvoClimateEntity): _attr_preset_modes = list(HA_PRESET_TO_EVO) - _evo_device: evo.Zone # mypy hint + _evo_device: evo.Zone + _evo_id_attr = "zone_id" + _evo_state_attr_names = (SZ_SETPOINT_STATUS, SZ_TEMPERATURE_STATUS) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.Zone + ) -> None: """Initialize an evohome-compatible heating zone.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.zoneId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - if evo_device.modelType.startswith("VisionProWifi"): + if evo_device.model.startswith("VisionProWifi"): # this system does not have a distinct ID for the zone - self._attr_unique_id = f"{evo_device.zoneId}z" + self._attr_unique_id = f"{evo_device.id}z" else: - self._attr_unique_id = evo_device.zoneId + self._attr_unique_id = evo_device.id - if evo_broker.client_v1: + if coordinator.client_v1: self._attr_precision = PRECISION_TENTHS else: - self._attr_precision = self._evo_device.setpointCapabilities[ - "valueResolution" + self._attr_precision = self._evo_device.setpoint_capabilities[ + "value_resolution" ] self._attr_supported_features = ( @@ -188,7 +176,7 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (setpoint override) for a zone.""" if service == EvoService.RESET_ZONE_OVERRIDE: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) return # otherwise it is EvoService.SET_ZONE_OVERRIDE @@ -198,14 +186,14 @@ class EvoZone(EvoChild, EvoClimateEntity): duration: timedelta = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = self.setpoints.get("next_sp_from") else: until = dt_util.now() + data[ATTR_DURATION_UNTIL] else: until = None # indefinitely until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) @@ -217,7 +205,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF): return HVACMode.AUTO if self.target_temperature is None: return None @@ -233,10 +221,8 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) - if self._evo_device.mode is None: - return None + if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.mode) return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property @@ -245,8 +231,6 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-21 (in Celsius). """ - if self._evo_device.min_heat_setpoint is None: - return 5 return self._evo_device.min_heat_setpoint @property @@ -255,33 +239,27 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 21-35 (in Celsius). """ - if self._evo_device.max_heat_setpoint is None: - return 35 return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" - assert self._evo_device.setpointStatus is not None # mypy check - temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.mode == EVO_FOLLOW: + if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: + until = self._evo_device.until + if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.mode == EVO_TEMPOVER: - until = dt_util.parse_datetime( - self._evo_device.setpointStatus[SZ_UNTIL] - ) + until = self.setpoints.get("next_sp_from") until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set a Zone to one of its native EVO_* operating modes. + """Set a Zone to one of its native operating modes. Zones inherit their _effective_ operating mode from their Controller. @@ -298,41 +276,34 @@ class EvoZone(EvoChild, EvoClimateEntity): and 'Away', Zones to (by default) 12C. """ if hvac_mode == HVACMode.OFF: - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(self.min_temp, until=None) ) else: # HVACMode.HEAT - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to following the schedule.""" - evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) + evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EvoZoneMode.FOLLOW_SCHEDULE) - if evo_preset_mode == EVO_FOLLOW: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + if evo_preset_mode == EvoZoneMode.FOLLOW_SCHEDULE: + await self.coordinator.call_client_api(self._evo_device.reset()) return - if evo_preset_mode == EVO_TEMPOVER: + if evo_preset_mode == EvoZoneMode.TEMPORARY_OVERRIDE: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - else: # EVO_PERMOVER + until = self.setpoints.get("next_sp_from") + else: # EvoZoneMode.PERMANENT_OVERRIDE until = None temperature = self._evo_device.target_heat_temperature assert temperature is not None # mypy check until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) - async def async_update(self) -> None: - """Get the latest state data for a Zone.""" - await super().async_update() - - for attr in STATE_ATTRS_ZONES: - self._device_state_attrs[attr] = getattr(self._evo_device, attr) - class EvoController(EvoClimateEntity): """Base for any evohome-compatible controller. @@ -347,18 +318,22 @@ class EvoController(EvoClimateEntity): _attr_icon = "mdi:thermostat" _attr_precision = PRECISION_TENTHS - _evo_device: evo.ControlSystem # mypy hint + _evo_device: evo.ControlSystem + _evo_id_attr = "system_id" + _evo_state_attr_names = (SZ_SYSTEM_MODE_STATUS,) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem + ) -> None: """Initialize an evohome-compatible controller.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.systemId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - self._attr_unique_id = evo_device.systemId + self._attr_unique_id = evo_device.id self._attr_name = evo_device.location.name - self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowedSystemModes] + self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowed_system_modes] self._attr_preset_modes = [ TCS_PRESET_TO_HA[m] for m in self._evo_modes if m in list(TCS_PRESET_TO_HA) ] @@ -376,7 +351,7 @@ class EvoController(EvoClimateEntity): if service == EvoService.SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] else: # otherwise it is EvoService.RESET_SYSTEM - mode = EVO_RESET + mode = EvoSystemMode.AUTO_WITH_RESET if ATTR_DURATION_DAYS in data: until = dt_util.start_of_local_day() @@ -390,18 +365,24 @@ class EvoController(EvoClimateEntity): await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: - """Set a Controller to any of its native EVO_* operating modes.""" + async def _set_tcs_mode( + self, mode: EvoSystemMode, until: datetime | None = None + ) -> None: + """Set a Controller to any of its native operating modes.""" until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( - self._evo_device.set_mode(mode, until=until) # type: ignore[arg-type] + await self.coordinator.call_client_api( + self._evo_device.set_mode(mode, until=until) ) @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - evo_mode = self._evo_device.system_mode - return HVACMode.OFF if evo_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT + evo_mode = self._evo_device.mode + return ( + HVACMode.OFF + if evo_mode in (EvoSystemMode.HEATING_OFF, EvoSystemMode.OFF) + else HVACMode.HEAT + ) @property def current_temperature(self) -> float | None: @@ -410,18 +391,14 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperature - for z in self._evo_device.zones.values() - if z.temperature is not None + z.temperature for z in self._evo_device.zones if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if not self._evo_device.system_mode: - return None - return TCS_PRESET_TO_HA.get(self._evo_device.system_mode) + return TCS_PRESET_TO_HA.get(self._evo_device.mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" @@ -429,25 +406,40 @@ class EvoController(EvoClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set an operating mode for a Controller.""" + + evo_mode: EvoSystemMode + if hvac_mode == HVACMode.HEAT: - evo_mode = EVO_AUTO if EVO_AUTO in self._evo_modes else "Heat" + evo_mode = ( + EvoSystemMode.AUTO + if EvoSystemMode.AUTO in self._evo_modes + else EvoSystemMode.HEAT + ) elif hvac_mode == HVACMode.OFF: - evo_mode = EVO_HEATOFF if EVO_HEATOFF in self._evo_modes else "Off" + evo_mode = ( + EvoSystemMode.HEATING_OFF + if EvoSystemMode.HEATING_OFF in self._evo_modes + else EvoSystemMode.OFF + ) else: raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}") await self._set_tcs_mode(evo_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to 'Auto' mode.""" - await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EvoSystemMode.AUTO)) - async def async_update(self) -> None: - """Get the latest state data for a Controller.""" - self._device_state_attrs = {} + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - attrs = self._device_state_attrs - for attr in STATE_ATTRS_TCS: - if attr == SZ_ACTIVE_FAULTS: - attrs["activeSystemFaults"] = getattr(self._evo_device, attr) - else: - attrs[attr] = getattr(self._evo_device, attr) + self._device_state_attrs = { + "activeSystemFaults": self._evo_device.active_faults + + self._evo_device.gateway.active_faults + } + + super()._handle_coordinator_update() + + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + self._handle_coordinator_update() diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 3ebe6954fea..12642addfa4 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -11,31 +11,8 @@ DOMAIN: Final = "evohome" STORAGE_VER: Final = 1 STORAGE_KEY: Final = DOMAIN -# The Parent's (i.e. TCS, Controller) operating mode is one of: -EVO_RESET: Final = "AutoWithReset" -EVO_AUTO: Final = "Auto" -EVO_AUTOECO: Final = "AutoWithEco" -EVO_AWAY: Final = "Away" -EVO_DAYOFF: Final = "DayOff" -EVO_CUSTOM: Final = "Custom" -EVO_HEATOFF: Final = "HeatingOff" - -# The Children's (i.e. Dhw, Zone) operating mode is one of: -EVO_FOLLOW: Final = "FollowSchedule" # the operating mode is 'inherited' from the TCS -EVO_TEMPOVER: Final = "TemporaryOverride" -EVO_PERMOVER: Final = "PermanentOverride" - -# These two are used only to help prevent E501 (line too long) violations -GWS: Final = "gateways" -TCS: Final = "temperatureControlSystems" - -UTC_OFFSET: Final = "currentOffsetMinutes" - CONF_LOCATION_IDX: Final = "location_idx" -ACCESS_TOKEN: Final = "access_token" -ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" -REFRESH_TOKEN: Final = "refresh_token" USER_DATA: Final = "user_data" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 943bd6605b4..7b197f1b643 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -4,109 +4,143 @@ from __future__ import annotations from collections.abc import Awaitable from datetime import timedelta +from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Any +from typing import Any -import evohomeasync as ev1 -from evohomeasync.schema import SZ_ID, SZ_TEMP -import evohomeasync2 as evo -from evohomeasync2.schema.const import ( +import evohomeasync as ec1 +import evohomeasync2 as ec2 +from evohomeasync2.const import ( SZ_GATEWAY_ID, SZ_GATEWAY_INFO, + SZ_GATEWAYS, SZ_LOCATION_ID, SZ_LOCATION_INFO, + SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, + SZ_USE_DAYLIGHT_SAVE_SWITCHING, ) +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from .const import CONF_LOCATION_IDX, DOMAIN, GWS, TCS, UTC_OFFSET -from .helpers import handle_evo_exception - -if TYPE_CHECKING: - from . import EvoSession - -_LOGGER = logging.getLogger(__name__.rpartition(".")[0]) +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -class EvoBroker: - """Broker for evohome client broker.""" +class EvoDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinator for evohome integration/client.""" - loc_idx: int - loc: evo.Location - loc_utc_offset: timedelta - tcs: evo.ControlSystem + # These will not be None after _async_setup()) + loc: ec2.Location + tcs: ec2.ControlSystem - def __init__(self, sess: EvoSession) -> None: - """Initialize the evohome broker and its data structure.""" + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + client_v2: ec2.EvohomeClient, + *, + name: str, + update_interval: timedelta, + location_idx: int, + client_v1: ec1.EvohomeClient | None = None, + ) -> None: + """Class to manage fetching data.""" - self._sess = sess - self.hass = sess.hass + super().__init__( + hass, + logger, + config_entry=None, + name=name, + update_interval=update_interval, + ) - assert sess.client_v2 is not None # mypy + self.client = client_v2 + self.client_v1 = client_v1 - self.client = sess.client_v2 - self.client_v1 = sess.client_v1 + self.loc_idx = location_idx + self.data: EvoLocStatusResponseT = None # type: ignore[assignment] self.temps: dict[str, float | None] = {} - def validate_location(self, loc_idx: int) -> bool: - """Get the default TCS of the specified location.""" + self._first_refresh_done = False # get schedules only after first refresh - self.loc_idx = loc_idx + # our version of async_config_entry_first_refresh()... + async def async_first_refresh(self) -> None: + """Refresh data for the first time when integration is setup. - assert self.client.installation_info is not None # mypy + This integration does not have config flow, so it is inappropriate to + invoke `async_config_entry_first_refresh()`. + """ + + # can't replicate `if not await self.__wrap_async_setup():` (is mangled), so... + if not await self._DataUpdateCoordinator__wrap_async_setup(): # type: ignore[attr-defined] + return + + await self._async_refresh( + log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True + ) + + async def _async_setup(self) -> None: + """Set up the coordinator. + + Fetch the user information, and the configuration of their locations. + """ try: - loc_config = self.client.installation_info[loc_idx] - except IndexError: - _LOGGER.error( - ( - "Config error: '%s' = %s, but the valid range is 0-%s. " - "Unable to continue. Fix any configuration errors and restart HA" - ), - CONF_LOCATION_IDX, - loc_idx, - len(self.client.installation_info) - 1, - ) - return False + await self.client.update(dont_update_status=True) # only config for now + except ec2.EvohomeError as err: + raise UpdateFailed(err) from err - self.loc = self.client.locations[loc_idx] - self.loc_utc_offset = timedelta(minutes=self.loc.timeZone[UTC_OFFSET]) - self.tcs = self.loc._gateways[0]._control_systems[0] # noqa: SLF001 + try: + self.loc = self.client.locations[self.loc_idx] + except IndexError as err: + raise UpdateFailed( + f""" + Config error: 'location_idx' = {self.loc_idx}, + but the valid range is 0-{len(self.client.locations) - 1}. + Unable to continue. Fix any configuration errors and restart HA + """ + ) from err - if _LOGGER.isEnabledFor(logging.DEBUG): + self.tcs = self.loc.gateways[0].systems[0] + + if self.logger.isEnabledFor(logging.DEBUG): loc_info = { - SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID], - SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE], + SZ_LOCATION_ID: self.loc.id, + SZ_TIME_ZONE: self.loc.config[SZ_TIME_ZONE], + SZ_USE_DAYLIGHT_SAVE_SWITCHING: self.loc.config[ + SZ_USE_DAYLIGHT_SAVE_SWITCHING + ], } gwy_info = { - SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID], - TCS: loc_config[GWS][0][TCS], + SZ_GATEWAY_ID: self.loc.gateways[0].id, + SZ_TEMPERATURE_CONTROL_SYSTEMS: [ + self.loc.gateways[0].systems[0].config + ], } config = { SZ_LOCATION_INFO: loc_info, - GWS: [{SZ_GATEWAY_INFO: gwy_info}], + SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - _LOGGER.debug("Config = %s", config) - - return True + self.logger.debug("Config = %s", config) async def call_client_api( self, client_api: Awaitable[dict[str, Any] | None], - update_state: bool = True, + request_refresh: bool = True, ) -> dict[str, Any] | None: - """Call a client API and update the broker state if required.""" + """Call a client API and update the Coordinator state if required.""" try: result = await client_api - except evo.RequestFailed as err: - handle_evo_exception(err) + + except ec2.ApiRequestFailedError as err: + self.logger.error(err) return None - if update_state: # wait a moment for system to quiesce before updating state - await self.hass.data[DOMAIN]["coordinator"].async_request_refresh() + if request_refresh: # wait a moment for system to quiesce before updating state + await self.async_request_refresh() # hass.async_create_task() won't help return result @@ -115,80 +149,82 @@ class EvoBroker: assert self.client_v1 is not None # mypy check - old_session_id = self._sess.session_id - try: - temps = await self.client_v1.get_temperatures() + await self.client_v1.update() - except ev1.InvalidSchema as err: - _LOGGER.warning( + except ec1.BadUserCredentialsError as err: + self.logger.warning( ( "Unable to obtain high-precision temperatures. " - "It appears the JSON schema is not as expected, " - "so the high-precision feature will be disabled until next restart." - "Message is: %s" + "The feature will be disabled until next restart: %r" ), err, ) self.client_v1 = None - except ev1.RequestFailed as err: - _LOGGER.warning( + except ec1.EvohomeError as err: + self.logger.warning( ( "Unable to obtain the latest high-precision temperatures. " - "Check your network and the vendor's service status page. " - "Proceeding without high-precision temperatures for now. " - "Message is: %s" + "They will be ignored this refresh cycle: %r" ), err, ) self.temps = {} # high-precision temps now considered stale - except Exception: - self.temps = {} # high-precision temps now considered stale - raise - else: - if str(self.client_v1.location_id) != self.loc.locationId: - _LOGGER.warning( - "The v2 API's configured location doesn't match " - "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled until next restart" - ) - self.client_v1 = None - else: - self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} + self.temps = await self.client_v1.location_by_id[ + self.loc.id + ].get_temperatures(dont_update_status=True) - finally: - if self.client_v1 and self.client_v1.broker.session_id != old_session_id: - await self._sess.save_auth_tokens() - - _LOGGER.debug("Temperatures = %s", self.temps) + self.logger.debug("Status (high-res temps) = %s", self.temps) async def _update_v2_api_state(self, *args: Any) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token # maybe receive a new token? - try: - status = await self.loc.refresh_status() - except evo.RequestFailed as err: - handle_evo_exception(err) - else: - async_dispatcher_send(self.hass, DOMAIN) - _LOGGER.debug("Status = %s", status) - finally: - if access_token != self.client.access_token: - await self._sess.save_auth_tokens() + status = await self.loc.update() - async def async_update(self, *args: Any) -> None: - """Get the latest state data of an entire Honeywell TCC Location. + except ec2.ApiRequestFailedError as err: + if err.status != HTTPStatus.TOO_MANY_REQUESTS: + raise UpdateFailed(err) from err + + raise UpdateFailed( + f""" + The vendor's API rate limit has been exceeded. + Consider increasing the {CONF_SCAN_INTERVAL} + """ + ) from err + + except ec2.EvohomeError as err: + raise UpdateFailed(err) from err + + self.logger.debug("Status = %s", status) + + async def _update_v2_schedules(self) -> None: + for zone in self.tcs.zones: + await zone.get_schedule() + + if dhw := self.tcs.hotwater: + await dhw.get_schedule() + + async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] + """Fetch the latest state of an entire TCC Location. This includes state data for a Controller and all its child devices, such as the operating mode of the Controller and the current temp of its children (e.g. Zones, DHW controller). """ - await self._update_v2_api_state() + await self._update_v2_api_state() # may raise UpdateFailed if self.client_v1: - await self._update_v1_api_temps() + await self._update_v1_api_temps() # will never raise UpdateFailed + + # to speed up HA startup, don't update entity schedules during initial + # async_first_refresh(), only during subsequent async_refresh()... + if self._first_refresh_done: + await self._update_v2_schedules() + else: + self._first_refresh_done = True + + return self.loc.status diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index a42d8ef7582..11215dd47b6 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,55 +1,49 @@ """Base for evohome entity.""" -from datetime import datetime, timedelta, timezone +from collections.abc import Mapping +from datetime import UTC, datetime import logging from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_HEAT_SETPOINT, - SZ_SETPOINT_STATUS, - SZ_STATE_STATUS, - SZ_SYSTEM_MODE_STATUS, - SZ_TIME_UNTIL, - SZ_UNTIL, -) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import EvoBroker, EvoService -from .const import DOMAIN -from .helpers import convert_dict, convert_until +from .const import DOMAIN, EvoService +from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class EvoDevice(Entity): +class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): """Base for any evohome-compatible entity (controller, DHW, zone). This includes the controller, (1 to 12) heating zones and (optionally) a DHW controller. """ - _attr_should_poll = False + _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone + _evo_id_attr: str + _evo_state_attr_names: tuple[str, ...] def __init__( self, - evo_broker: EvoBroker, + coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, ) -> None: """Initialize an evohome-compatible entity (TCS, DHW, zone).""" + super().__init__(coordinator, context=evo_device.id) self._evo_device = evo_device - self._evo_broker = evo_broker self._device_state_attrs: dict[str, Any] = {} - async def async_refresh(self, payload: dict | None = None) -> None: + async def process_signal(self, payload: dict | None = None) -> None: """Process any signals.""" + if payload is None: - self.async_schedule_update_ha_state(force_refresh=True) - return + raise NotImplementedError if payload["unique_id"] != self._attr_unique_id: return if payload["service"] in ( @@ -69,40 +63,46 @@ class EvoDevice(Entity): raise NotImplementedError @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" - status = self._device_state_attrs - if SZ_SYSTEM_MODE_STATUS in status: - convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL) - if SZ_SETPOINT_STATUS in status: - convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL) - if SZ_STATE_STATUS in status: - convert_until(status[SZ_STATE_STATUS], SZ_UNTIL) - - return {"status": convert_dict(status)} + return {"status": self._device_state_attrs} async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - async_dispatcher_connect(self.hass, DOMAIN, self.async_refresh) + await super().async_added_to_hass() + + async_dispatcher_connect(self.hass, DOMAIN, self.process_signal) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + self._device_state_attrs[self._evo_id_attr] = self._evo_device.id + + for attr in self._evo_state_attr_names: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) + + super()._handle_coordinator_update() -class EvoChild(EvoDevice): +class EvoChild(EvoEntity): """Base for any evohome-compatible child entity (DHW, zone). This includes (1 to 12) heating zones and (optionally) a DHW controller. """ - _evo_id: str # mypy hint + _evo_device: evo.HotWater | evo.Zone + _evo_id: str def __init__( - self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater | evo.Zone ) -> None: """Initialize an evohome-compatible child entity (DHW, zone).""" - super().__init__(evo_broker, evo_device) + super().__init__(coordinator, evo_device) self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] = {} + self._schedule: dict[str, Any] | None = None self._setpoints: dict[str, Any] = {} @property @@ -111,101 +111,78 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if (temp := self._evo_broker.temps.get(self._evo_id)) is not None: + if (temp := self.coordinator.temps.get(self._evo_id)) is not None: # use high-precision temps if available return temp return self._evo_device.temperature @property - def setpoints(self) -> dict[str, Any]: + def setpoints(self) -> Mapping[str, Any]: """Return the current/next setpoints from the schedule. Only Zones & DHW controllers (but not the TCS) can have schedules. """ - def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: - dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset - return dt_util.as_local(dt_aware) + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint + next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint - if not (schedule := self._schedule.get("DailySchedules")): - return {} # no scheduled setpoints when {'DailySchedules': []} + key = "temp" if isinstance(self._evo_device, evo.Zone) else "state" - # get dt in the same TZ as the TCS location, so we can compare schedule times - day_time = dt_util.now().astimezone(timezone(self._evo_broker.loc_utc_offset)) - day_of_week = day_time.weekday() # for evohome, 0 is Monday - time_of_day = day_time.strftime("%H:%M:%S") - - try: - # Iterate today's switchpoints until past the current time of day... - day = schedule[day_of_week] - sp_idx = -1 # last switchpoint of the day before - for i, tmp in enumerate(day["Switchpoints"]): - if time_of_day > tmp["TimeOfDay"]: - sp_idx = i # current setpoint - else: - break - - # Did this setpoint start yesterday? Does the next setpoint start tomorrow? - this_sp_day = -1 if sp_idx == -1 else 0 - next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 - - for key, offset, idx in ( - ("this", this_sp_day, sp_idx), - ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), - ): - sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") - day = schedule[(day_of_week + offset) % 7] - switchpoint = day["Switchpoints"][idx] - - switchpoint_time_of_day = dt_util.parse_datetime( - f"{sp_date}T{switchpoint['TimeOfDay']}" - ) - assert switchpoint_time_of_day is not None # mypy check - dt_aware = _dt_evo_to_aware( - switchpoint_time_of_day, self._evo_broker.loc_utc_offset - ) - - self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() - try: - self._setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT] - except KeyError: - self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] - - except IndexError: - self._setpoints = {} - _LOGGER.warning( - "Failed to get setpoints, report as an issue if this error persists", - exc_info=True, - ) + self._setpoints = { + "this_sp_from": this_sp_dtm, + f"this_sp_{key}": this_sp_val, + "next_sp_from": next_sp_dtm, + f"next_sp_{key}": next_sp_val, + } return self._setpoints - async def _update_schedule(self) -> None: + async def _update_schedule(self, force_refresh: bool = False) -> None: """Get the latest schedule, if any.""" - assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + async def get_schedule() -> None: + try: + schedule = await self.coordinator.call_client_api( + self._evo_device.get_schedule(), # type: ignore[arg-type] + request_refresh=False, + ) + except evo.InvalidScheduleError as err: + _LOGGER.warning( + "%s: Unable to retrieve a valid schedule: %s", + self._evo_device, + err, + ) + self._schedule = {} + return + else: + self._schedule = schedule or {} # mypy hint - try: - schedule = await self._evo_broker.call_client_api( - self._evo_device.get_schedule(), update_state=False + _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) + + if ( + force_refresh + or self._schedule is None + or ( + (until := self._setpoints.get("next_sp_from")) is not None + and until < datetime.now(UTC) ) - except evo.InvalidSchedule as err: - _LOGGER.warning( - "%s: Unable to retrieve a valid schedule: %s", - self._evo_device, - err, - ) - self._schedule = {} - else: - self._schedule = schedule or {} + ): # must use self._setpoints, not self.setpoints + await get_schedule() - _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) + _ = self.setpoints # update the setpoints attr - async def async_update(self) -> None: - """Get the latest state data.""" - next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") - next_sp_from_dt = dt_util.parse_datetime(next_sp_from) - if next_sp_from_dt is None or dt_util.now() >= next_sp_from_dt: - await self._update_schedule() # no schedule, or it's out-of-date + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - self._device_state_attrs = {"setpoints": self.setpoints} + self._device_state_attrs = { + "activeFaults": self._evo_device.active_faults, + "setpoints": self._setpoints, + } + + super()._handle_coordinator_update() + + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + await self._update_schedule() + self._handle_coordinator_update() diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py deleted file mode 100644 index 0e2de36eb47..00000000000 --- a/homeassistant/components/evohome/helpers.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Support for (EMEA/EU-based) Honeywell TCC systems.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from http import HTTPStatus -import logging -import re -from typing import Any - -import evohomeasync2 as evo - -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.util import dt as dt_util - -_LOGGER = logging.getLogger(__name__) - - -def dt_local_to_aware(dt_naive: datetime) -> datetime: - """Convert a local/naive datetime to TZ-aware.""" - dt_aware = dt_util.now() + (dt_naive - datetime.now()) - if dt_aware.microsecond >= 500000: - dt_aware += timedelta(seconds=1) - return dt_aware.replace(microsecond=0) - - -def dt_aware_to_naive(dt_aware: datetime) -> datetime: - """Convert a TZ-aware datetime to naive/local.""" - dt_naive = datetime.now() + (dt_aware - dt_util.now()) - if dt_naive.microsecond >= 500000: - dt_naive += timedelta(seconds=1) - return dt_naive.replace(microsecond=0) - - -def convert_until(status_dict: dict, until_key: str) -> None: - """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" - if until_key in status_dict and ( # only present for certain modes - dt_utc_naive := dt_util.parse_datetime(status_dict[until_key]) - ): - status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() - - -def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: - """Recursively convert a dict's keys to snake_case.""" - - def convert_key(key: str) -> str: - """Convert a string to snake_case.""" - string = re.sub(r"[\-\.\s]", "_", str(key)) - return ( - (string[0]).lower() - + re.sub( - r"[A-Z]", - lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] - string[1:], - ) - ) - - return { - (convert_key(k) if isinstance(k, str) else k): ( - convert_dict(v) if isinstance(v, dict) else v - ) - for k, v in dictionary.items() - } - - -def handle_evo_exception(err: evo.RequestFailed) -> None: - """Return False if the exception can't be ignored.""" - - try: - raise err - - except evo.AuthenticationFailed: - _LOGGER.error( - ( - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: %s" - ), - err, - ) - - except evo.RequestFailed: - if err.status is None: - _LOGGER.warning( - ( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: %s" - ), - err, - ) - - elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: - _LOGGER.warning( - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page" - ) - - elif err.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.warning( - ( - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the %s" - ), - CONF_SCAN_INTERVAL, - ) - - else: - raise # we don't expect/handle any other Exceptions diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 22edadad7f4..823ad7be5df 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@zxdavb"], "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", - "loggers": ["evohomeasync", "evohomeasync2"], + "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==0.4.21"] + "requirements": ["evohome-async==1.0.2"] } diff --git a/homeassistant/components/evohome/storage.py b/homeassistant/components/evohome/storage.py new file mode 100644 index 00000000000..b078c33b305 --- /dev/null +++ b/homeassistant/components/evohome/storage.py @@ -0,0 +1,118 @@ +"""Support for (EMEA/EU-based) Honeywell TCC systems.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any, NotRequired, TypedDict + +from evohomeasync.auth import ( + SZ_SESSION_ID, + SZ_SESSION_ID_EXPIRES, + AbstractSessionManager, +) +from evohomeasync2.auth import AbstractTokenManager + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import STORAGE_KEY, STORAGE_VER + + +class _SessionIdEntryT(TypedDict): + session_id: str + session_id_expires: NotRequired[str] # dt.isoformat() # TZ-aware + + +class _TokenStoreT(TypedDict): + username: str + refresh_token: str + access_token: str + access_token_expires: str # dt.isoformat() # TZ-aware + session_id: NotRequired[str] + session_id_expires: NotRequired[str] # dt.isoformat() # TZ-aware + + +class TokenManager(AbstractTokenManager, AbstractSessionManager): + """A token manager that uses a cache file to store the tokens.""" + + def __init__( + self, + hass: HomeAssistant, + *args: Any, + **kwargs: Any, + ) -> None: + """Initialise the token manager.""" + super().__init__(*args, **kwargs) + + self._store = Store(hass, STORAGE_VER, STORAGE_KEY) # type: ignore[var-annotated] + self._store_initialized = False # True once cache loaded first time + + async def get_access_token(self) -> str: + """Return a valid access token. + + If the cached entry is not valid, will fetch a new access token. + """ + + if not self._store_initialized: + await self._load_cache_from_store() + + return await super().get_access_token() + + async def get_session_id(self) -> str: + """Return a valid session id. + + If the cached entry is not valid, will fetch a new session id. + """ + + if not self._store_initialized: + await self._load_cache_from_store() + + return await super().get_session_id() + + async def _load_cache_from_store(self) -> None: + """Load the user entry from the cache. + + Assumes single reader/writer. Reads only once, at initialization. + """ + + cache: _TokenStoreT = await self._store.async_load() or {} # type: ignore[assignment] + self._store_initialized = True + + if not cache or cache["username"] != self._client_id: + return + + if SZ_SESSION_ID in cache: + self._import_session_id(cache) # type: ignore[arg-type] + self._import_access_token(cache) + + def _import_session_id(self, session: _SessionIdEntryT) -> None: # type: ignore[override] + """Extract the session id from a (serialized) dictionary.""" + # base class method overridden because session_id_expired is NotRequired here + + self._session_id = session[SZ_SESSION_ID] + + session_id_expires = session.get(SZ_SESSION_ID_EXPIRES) + if session_id_expires is None: + self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15) + else: + self._session_id_expires = datetime.fromisoformat(session_id_expires) + + async def save_access_token(self) -> None: # an abstractmethod + """Save the access token (and expiry dtm, refresh token) to the cache.""" + await self.save_cache_to_store() + + async def save_session_id(self) -> None: # an abstractmethod + """Save the session id (and expiry dtm) to the cache.""" + await self.save_cache_to_store() + + async def save_cache_to_store(self) -> None: + """Save the access token (and session id, if any) to the cache. + + Assumes a single reader/writer. Writes whenever new data has been fetched. + """ + + cache = {"username": self._client_id} | self._export_access_token() + if self._session_id: + cache |= self._export_session_id() + + await self._store.async_save(cache) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 2c3cf9de12d..7ea0fb3a2d9 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -3,17 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_ACTIVE_FAULTS, - SZ_DHW_ID, - SZ_OFF, - SZ_ON, - SZ_STATE_STATUS, - SZ_TEMPERATURE_STATUS, -) +from evohomeasync2.const import SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS +from evohomeasync2.schemas.const import DhwState as EvoDhwState, ZoneMode as EvoZoneMode from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -31,22 +25,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER +from . import EVOHOME_KEY +from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild -if TYPE_CHECKING: - from . import EvoBroker - - _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" -HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: SZ_ON, STATE_OFF: SZ_OFF} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: EvoDhwState.ON, STATE_OFF: EvoDhwState.OFF} EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -STATE_ATTRS_DHW = [SZ_DHW_ID, SZ_ACTIVE_FAULTS, SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS] - async def async_setup_platform( hass: HomeAssistant, @@ -58,19 +47,22 @@ async def async_setup_platform( if discovery_info is None: return - broker: EvoBroker = hass.data[DOMAIN]["broker"] + coordinator = hass.data[EVOHOME_KEY].coordinator + tcs = hass.data[EVOHOME_KEY].tcs - assert broker.tcs.hotwater is not None # mypy check + assert tcs.hotwater is not None # mypy check _LOGGER.debug( "Adding: DhwController (%s), id=%s", - broker.tcs.hotwater.TYPE, - broker.tcs.hotwater.dhwId, + tcs.hotwater.type, + tcs.hotwater.id, ) - new_entity = EvoDHW(broker, broker.tcs.hotwater) + entity = EvoDHW(coordinator, tcs.hotwater) - async_add_entities([new_entity], update_before_add=True) + async_add_entities([entity]) + + await entity.update_attrs() class EvoDHW(EvoChild, WaterHeaterEntity): @@ -81,19 +73,23 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _attr_operation_list = list(HA_STATE_TO_EVO) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _evo_device: evo.HotWater # mypy hint + _evo_device: evo.HotWater + _evo_id_attr = "dhw_id" + _evo_state_attr_names = (SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater + ) -> None: """Initialize an evohome-compatible DHW controller.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.dhwId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - self._attr_unique_id = evo_device.dhwId + self._attr_unique_id = evo_device.id self._attr_name = evo_device.name # is static self._attr_precision = ( - PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE + PRECISION_TENTHS if coordinator.client_v1 else PRECISION_WHOLE ) self._attr_supported_features = ( WaterHeaterEntityFeature.AWAY_MODE | WaterHeaterEntityFeature.OPERATION_MODE @@ -102,19 +98,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): @property def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.mode == EVO_FOLLOW: + if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: return STATE_AUTO - if (device_state := self._evo_device.state) is None: - return None - return EVO_STATE_TO_HA[device_state] + return EVO_STATE_TO_HA[self._evo_device.state] @property def is_away_mode_on(self) -> bool | None: """Return True if away mode is on.""" - if self._evo_device.state is None: - return None is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF - is_permanent = self._evo_device.mode == EVO_PERMOVER + is_permanent = self._evo_device.mode == EvoZoneMode.PERMANENT_OVERRIDE return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: @@ -123,40 +115,31 @@ class EvoDHW(EvoChild, WaterHeaterEntity): Except for Auto, the mode is only until the next SetPoint. """ if operation_mode == STATE_AUTO: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) else: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = self.setpoints.get("next_sp_from") until = dt_util.as_utc(until) if until else None if operation_mode == STATE_ON: - await self._evo_broker.call_client_api( - self._evo_device.set_on(until=until) - ) + await self.coordinator.call_client_api(self._evo_device.on(until=until)) else: # STATE_OFF - await self._evo_broker.call_client_api( - self._evo_device.set_off(until=until) + await self.coordinator.call_client_api( + self._evo_device.off(until=until) ) async def async_turn_away_mode_on(self) -> None: """Turn away mode on.""" - await self._evo_broker.call_client_api(self._evo_device.set_off()) + await self.coordinator.call_client_api(self._evo_device.off()) async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" - await self._evo_broker.call_client_api(self._evo_device.set_on()) + await self.coordinator.call_client_api(self._evo_device.on()) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" - await self._evo_broker.call_client_api(self._evo_device.set_off()) - - async def async_update(self) -> None: - """Get the latest state data for a DHW controller.""" - await super().async_update() - - for attr in STATE_ATTRS_DHW: - self._device_state_attrs[attr] = getattr(self._evo_device, attr) + await self.coordinator.call_client_api(self._evo_device.off()) diff --git a/requirements_all.txt b/requirements_all.txt index 0fdf048bc63..13abe012fcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -893,7 +893,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 053acf5bd86..580efd88992 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -759,7 +759,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 6daab3f32bb..5f60bc418e3 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -3,26 +3,26 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable -from datetime import datetime, timedelta, timezone +from datetime import timedelta, timezone from http import HTTPMethod from typing import Any from unittest.mock import MagicMock, patch -from aiohttp import ClientSession from evohomeasync2 import EvohomeClient -from evohomeasync2.broker import Broker -from evohomeasync2.controlsystem import ControlSystem +from evohomeasync2.auth import AbstractTokenManager, Auth +from evohomeasync2.control_system import ControlSystem from evohomeasync2.zone import Zone +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN -from homeassistant.const import Platform +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from homeassistant.util.json import JsonArrayType, JsonObjectType -from .const import ACCESS_TOKEN, REFRESH_TOKEN, USERNAME +from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME from tests.common import load_json_array_fixture, load_json_object_fixture @@ -64,44 +64,69 @@ def zone_schedule_fixture(install: str) -> JsonObjectType: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) -def mock_get_factory(install: str) -> Callable: +def mock_post_request(install: str) -> Callable: + """Obtain an access token via a POST to the vendor's web API.""" + + async def post_request( + self: AbstractTokenManager, url: str, /, **kwargs: Any + ) -> JsonArrayType | JsonObjectType: + """Obtain an access token via a POST to the vendor's web API.""" + + if "Token" in url: + return { + "access_token": f"new_{ACCESS_TOKEN}", + "token_type": "bearer", + "expires_in": 1800, + "refresh_token": f"new_{REFRESH_TOKEN}", + # "scope": "EMEA-V1-Basic EMEA-V1-Anonymous", # optional + } + + if "session" in url: + return {"sessionId": f"new_{SESSION_ID}"} + + pytest.fail(f"Unexpected request: {HTTPMethod.POST} {url}") + + return post_request + + +def mock_make_request(install: str) -> Callable: """Return a get method for a specified installation.""" - async def mock_get( - self: Broker, url: str, **kwargs: Any + async def make_request( + self: Auth, method: HTTPMethod, url: str, **kwargs: Any ) -> JsonArrayType | JsonObjectType: """Return the JSON for a HTTP get of a given URL.""" - # a proxy for the behaviour of the real web API - if self.refresh_token is None: - self.refresh_token = f"new_{REFRESH_TOKEN}" + if method != HTTPMethod.GET: + pytest.fail(f"Unmocked method: {method} {url}") - if ( - self.access_token_expires is None - or self.access_token_expires < datetime.now() - ): - self.access_token = f"new_{ACCESS_TOKEN}" - self.access_token_expires = datetime.now() + timedelta(minutes=30) + await self._headers() # assume a valid GET, and return the JSON for that web API - if url == "userAccount": # userAccount + if url == "accountInfo": # /v0/accountInfo + return {} # will throw a KeyError -> BadApiResponseError + + if url.startswith("locations/"): # /v0/locations?userId={id}&allData=True + return [] # user has no locations + + if url == "userAccount": # /v2/userAccount return user_account_config_fixture(install) - if url.startswith("location"): - if "installationInfo" in url: # location/installationInfo?userId={id} + if url.startswith("location/"): + if "installationInfo" in url: # /v2/location/installationInfo?userId={id} return user_locations_config_fixture(install) - if "location" in url: # location/{id}/status + if "status" in url: # /v2/location/{id}/status return location_status_fixture(install) elif "schedule" in url: - if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule + if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule return dhw_schedule_fixture(install) - if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule + if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule return zone_schedule_fixture(install) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") - return mock_get + return make_request @pytest.fixture @@ -137,9 +162,13 @@ async def setup_evohome( dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset))) with ( - patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, - patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), - patch("evohomeasync2.broker.Broker.get", mock_get_factory(install)), + # patch("homeassistant.components.evohome.ec1.EvohomeClient", return_value=None), + patch("homeassistant.components.evohome.ec2.EvohomeClient") as mock_client, + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request(install), + ), + patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)), ): evo: EvohomeClient | None = None @@ -155,12 +184,11 @@ async def setup_evohome( mock_client.assert_called_once() - assert mock_client.call_args.args[0] == config[CONF_USERNAME] - assert mock_client.call_args.args[1] == config[CONF_PASSWORD] + assert isinstance(evo, EvohomeClient) + assert evo._token_manager.client_id == config[CONF_USERNAME] + assert evo._token_manager._secret == config[CONF_PASSWORD] - assert isinstance(mock_client.call_args.kwargs["session"], ClientSession) - - assert evo and evo.account_info is not None + assert evo.user_account mock_client.return_value = evo yield mock_client @@ -170,39 +198,32 @@ async def setup_evohome( async def evohome( hass: HomeAssistant, config: dict[str, str], + freezer: FrozenDateTimeFactory, install: str, ) -> AsyncGenerator[MagicMock]: """Return the mocked evohome client for this install fixture.""" + freezer.move_to("2024-07-10T12:00:00Z") # so schedules are as expected + async for mock_client in setup_evohome(hass, config, install=install): yield mock_client @pytest.fixture -async def ctl_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def ctl_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's controller.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - ctl: ControlSystem = evo._get_single_tcs() + evo: EvohomeClient = evohome.return_value + ctl: ControlSystem = evo.tcs - yield f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" + return f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" @pytest.fixture -async def zone_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def zone_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's first zone.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - zone: Zone = list(evo._get_single_tcs().zones.values())[0] + evo: EvohomeClient = evohome.return_value + zone: Zone = evo.tcs.zones[0] - yield f"{Platform.CLIMATE}.{slugify(zone.name)}" + return f"{Platform.CLIMATE}.{slugify(zone.name)}" diff --git a/tests/components/evohome/fixtures/h032585/user_locations.json b/tests/components/evohome/fixtures/h032585/user_locations.json index b4ea2e5c420..c291d591c99 100644 --- a/tests/components/evohome/fixtures/h032585/user_locations.json +++ b/tests/components/evohome/fixtures/h032585/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "GMTStandardTime", "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", diff --git a/tests/components/evohome/fixtures/h099625/user_locations.json b/tests/components/evohome/fixtures/h099625/user_locations.json index cc32caccc73..31cac00ae9e 100644 --- a/tests/components/evohome/fixtures/h099625/user_locations.json +++ b/tests/components/evohome/fixtures/h099625/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "FLEStandardTime", "displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index ce7fcf2744e..23a15e3f64f 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -2,120 +2,120 @@ # name: test_ctl_set_hvac_mode[default] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h032585] list([ tuple( - 'Off', + , ), tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h099625] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[sys_004] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_off[default] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[h032585] list([ tuple( - 'Off', + , ), ]) # --- # name: test_ctl_turn_off[h099625] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[minimal] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[sys_004] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_on[default] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[h032585] list([ tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_turn_on[h099625] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[minimal] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[sys_004] list([ tuple( - 'Auto', + , ), ]) # --- @@ -137,16 +137,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -184,16 +184,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -230,21 +230,21 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20', + 'fault_type': 'TempZoneActuatorLowBattery', + 'since': '2022-03-02T04:50:20+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -282,16 +282,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -329,16 +329,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -376,16 +376,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -423,20 +423,20 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01', + 'fault_type': 'TempZoneActuatorCommunicationLost', + 'since': '2022-03-02T15:56:01+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -477,8 +477,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -513,16 +513,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -560,16 +560,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -606,17 +606,17 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -654,16 +654,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -701,16 +701,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -748,16 +748,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -795,16 +795,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -845,8 +845,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -881,16 +881,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 14.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -923,8 +923,8 @@ 'max_temp': 35, 'min_temp': 7, 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '416856', 'system_mode_status': dict({ 'is_permanent': True, @@ -959,16 +959,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1006,8 +1006,8 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '8557535', 'system_mode_status': dict({ 'is_permanent': True, @@ -1042,16 +1042,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1089,16 +1089,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1136,16 +1136,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1186,8 +1186,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -1222,8 +1222,12 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + dict({ + 'fault_type': 'GatewayCommunicationLost', + 'since': '2023-05-04T18:47:36.772704+02:00', + }), + ), 'system_id': '4187769', 'system_mode_status': dict({ 'is_permanent': True, @@ -1258,16 +1262,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 15.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+02:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+02:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1331,7 +1335,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1344,7 +1348,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1357,7 +1361,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1370,7 +1374,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1383,35 +1387,35 @@ 15.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h032585] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h099625] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[minimal] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 4cdeb28f445..771e2c20cba 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -13,11 +13,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -25,13 +25,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ @@ -60,11 +60,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -72,13 +72,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 325dd914bc0..b1b930c6382 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -65,7 +65,7 @@ async def test_ctl_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -76,14 +76,15 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -94,11 +95,12 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -134,7 +136,7 @@ async def test_ctl_turn_off( results = [] # SERVICE_TURN_OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_OFF, @@ -144,11 +146,12 @@ async def test_ctl_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -164,7 +167,7 @@ async def test_ctl_turn_on( results = [] # SERVICE_TURN_ON - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -174,11 +177,12 @@ async def test_ctl_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -194,7 +198,7 @@ async def test_zone_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -205,9 +209,7 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_HVAC_MODE: HVACMode.OFF with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -221,7 +223,9 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -243,7 +247,7 @@ async def test_zone_set_preset_mode( results = [] # SERVICE_SET_PRESET_MODE: none - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_PRESET_MODE, @@ -254,9 +258,7 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_PRESET_MODE: permanent with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -270,7 +272,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -288,7 +292,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -302,12 +308,10 @@ async def test_zone_set_preset_mode( async def test_zone_set_temperature( hass: HomeAssistant, zone_id: str, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test SERVICE_SET_TEMPERATURE of an evohome heating zone.""" - freezer.move_to("2024-07-10T12:00:00Z") results = [] # SERVICE_SET_TEMPERATURE: temperature @@ -322,7 +326,9 @@ async def test_zone_set_temperature( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == (19.1,) assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -352,7 +358,9 @@ async def test_zone_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -369,7 +377,7 @@ async def test_zone_turn_on( """Test SERVICE_TURN_ON of an evohome heating zone.""" # SERVICE_TURN_ON - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -379,6 +387,4 @@ async def test_zone_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() diff --git a/tests/components/evohome/test_coordinator.py b/tests/components/evohome/test_coordinator.py new file mode 100644 index 00000000000..7fb325d55b9 --- /dev/null +++ b/tests/components/evohome/test_coordinator.py @@ -0,0 +1,55 @@ +"""The tests for the evohome coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome import EvoData +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize("install", ["minimal"]) +async def test_setup_platform( + hass: HomeAssistant, + config: dict[str, str], + evohome: EvohomeClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities and their states after setup of evohome.""" + + evo_data: EvoData = hass.data.get(DOMAIN) # type: ignore[assignment] + update_interval: timedelta = evo_data.coordinator.update_interval # type: ignore[assignment] + + # confirm initial state after coordinator.async_first_refresh()... + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE + + with patch( + "homeassistant.components.evohome.coordinator.EvoDataUpdateCoordinator._async_update_data", + side_effect=UpdateFailed, + ): + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # confirm appropriate response to loss of state... + state = hass.states.get("climate.my_home") + assert state is not None and state.state == STATE_UNAVAILABLE + + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # if coordinator is working, the state will be restored + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 9b5fe6ad62d..d327bdf14b4 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -4,80 +4,130 @@ from __future__ import annotations from http import HTTPStatus import logging -from unittest.mock import patch +from unittest.mock import Mock, patch +import aiohttp from evohomeasync2 import EvohomeClient, exceptions as exc -from evohomeasync2.broker import _ERR_MSG_LOOKUP_AUTH, _ERR_MSG_LOOKUP_BASE import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN, EvoService from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import mock_post_request from .const import TEST_INSTALLS -SETUP_FAILED_ANTICIPATED = ( +_MSG_429 = ( + "You have exceeded the server's API rate limit. Wait a while " + "and try again (consider reducing your polling interval)." +) +_MSG_OTH = ( + "Unable to contact the vendor's server. Check your network " + "and review the vendor's status page, https://status.resideo.com." +) +_MSG_USR = ( + "Failed to authenticate. Check the username/password. Note that some " + "special characters accepted via the vendor's website are not valid here." +) + +LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429) +LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR) + +LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429) +LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR) + +LOG_FAIL_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: Authenticator response is invalid: Connection error", +) +LOG_FAIL_CREDENTIALS = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: {'error': 'invalid_grant'}", +) +LOG_FAIL_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 502 Bad Gateway, response=None", +) +LOG_FAIL_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 429 Too Many Requests, response=None", +) + +LOG_FGET_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "Connection error", +) +LOG_FGET_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "502 Bad Gateway, response=None", +) +LOG_FGET_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "429 Too Many Requests, response=None", +) + + +LOG_SETUP_FAILED = ( "homeassistant.setup", logging.ERROR, "Setup failed for 'evohome': Integration failed to initialize.", ) -SETUP_FAILED_UNEXPECTED = ( - "homeassistant.setup", - logging.ERROR, - "Error during setup of component evohome: ", + +EXC_BAD_CONNECTION = aiohttp.ClientConnectionError( + "Connection error", ) -AUTHENTICATION_FAILED = ( - "homeassistant.components.evohome.helpers", - logging.ERROR, - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: ", +EXC_BAD_CREDENTIALS = exc.AuthenticationFailedError( + "Authenticator response is invalid: {'error': 'invalid_grant'}", + status=HTTPStatus.BAD_REQUEST, ) -REQUEST_FAILED_NONE = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: ", +EXC_TOO_MANY_REQUESTS = aiohttp.ClientResponseError( + Mock(), + (), + status=HTTPStatus.TOO_MANY_REQUESTS, + message=HTTPStatus.TOO_MANY_REQUESTS.phrase, ) -REQUEST_FAILED_503 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page", -) -REQUEST_FAILED_429 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the scan_interval", +EXC_BAD_GATEWAY = aiohttp.ClientResponseError( + Mock(), (), status=HTTPStatus.BAD_GATEWAY, message=HTTPStatus.BAD_GATEWAY.phrase ) -REQUEST_FAILED_LOOKUP = { - None: [ - REQUEST_FAILED_NONE, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.SERVICE_UNAVAILABLE: [ - REQUEST_FAILED_503, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.TOO_MANY_REQUESTS: [ - REQUEST_FAILED_429, - SETUP_FAILED_ANTICIPATED, - ], +AUTHENTICATION_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED], +} + +CLIENT_REQUEST_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FGET_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FGET_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FGET_TOO_MANY, LOG_SETUP_FAILED], } -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_AUTH, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", AUTHENTICATION_TESTS) async def test_authentication_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -85,27 +135,24 @@ async def test_authentication_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.AuthenticationFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohome.credentials.CredentialsManagerBase._request", side_effect=exception + ), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == [ - AUTHENTICATION_FAILED, - SETUP_FAILED_ANTICIPATED, - ] + assert caplog.record_tuples == AUTHENTICATION_TESTS[exception] -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_BASE, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", CLIENT_REQUEST_TESTS) async def test_client_request_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -113,17 +160,19 @@ async def test_client_request_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.RequestFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request("default"), + ), + patch("evohome.auth.AbstractAuth._request", side_effect=exception), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == REQUEST_FAILED_LOOKUP.get( - status, [SETUP_FAILED_UNEXPECTED] - ) + assert caplog.record_tuples == CLIENT_REQUEST_TESTS[exception] @pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"]) @@ -148,7 +197,7 @@ async def test_service_refresh_system( """Test EvoService.REFRESH_SYSTEM of an evohome system.""" # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.refresh_status") as mock_fcn: + with patch("evohomeasync2.location.Location.update") as mock_fcn: await hass.services.async_call( DOMAIN, EvoService.REFRESH_SYSTEM, @@ -156,9 +205,7 @@ async def test_service_refresh_system( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", ["default"]) @@ -169,7 +216,7 @@ async def test_service_reset_system( """Test EvoService.RESET_SYSTEM of an evohome system.""" # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( DOMAIN, EvoService.RESET_SYSTEM, @@ -177,6 +224,4 @@ async def test_service_reset_system( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == ("AutoWithReset",) - assert mock_fcn.await_args.kwargs == {"until": None} + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index b3597352487..4528f1c8590 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -7,13 +7,7 @@ from typing import Any, Final, NotRequired, TypedDict import pytest -from homeassistant.components.evohome import ( - CONF_USERNAME, - DOMAIN, - STORAGE_KEY, - STORAGE_VER, - dt_aware_to_naive, -) +from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -22,7 +16,8 @@ from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME class _SessionDataT(TypedDict): - sessionId: str + session_id: str + session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00 class _TokenStoreT(TypedDict): @@ -65,7 +60,7 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": _TEST_STORAGE_BASE, "null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] - "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"sessionId": SESSION_ID}}, + "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"session_id": SESSION_ID}}, } TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { @@ -89,15 +84,12 @@ async def test_auth_tokens_null( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when no cached tokens in the store.""" + """Test credentials manager when cache is empty.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated without tokens, as cache was empty... - assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -120,17 +112,12 @@ async def test_auth_tokens_same( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when matching username.""" + """Test credentials manager when cache contains valid data for this user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated with the cached tokens... - assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN - assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert mock_client.call_args.kwargs[ - SZ_ACCESS_TOKEN_EXPIRES - ] == dt_aware_to_naive(ACCESS_TOKEN_EXP_DTM) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -150,7 +137,7 @@ async def test_auth_tokens_past( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens with matching username, but expired.""" + """Test credentials manager when cache contains expired data for this user.""" dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) @@ -160,19 +147,14 @@ async def test_auth_tokens_past( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated with the cached tokens... - assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN - assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert mock_client.call_args.kwargs[ - SZ_ACCESS_TOKEN_EXPIRES - ] == dt_aware_to_naive(dt_dtm) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] assert data[SZ_USERNAME] == USERNAME_SAME - assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) @@ -189,17 +171,13 @@ async def test_auth_tokens_diff( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when unmatched username.""" + """Test credentials manager when cache contains data for a different user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} + config["username"] = USERNAME_DIFF - async for mock_client in setup_evohome( - hass, config | {CONF_USERNAME: USERNAME_DIFF}, install=install - ): - # Confirm client was instantiated without tokens, as username was different... - assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index 8acfd469b59..a201ff63d1e 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -67,7 +67,7 @@ async def test_set_operation_mode( results = [] # SERVICE_SET_OPERATION_MODE: auto - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -78,12 +78,10 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -94,14 +92,16 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} results.append(mock_fcn.await_args.kwargs) # SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -112,7 +112,9 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} @@ -126,7 +128,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone.""" # set_away_mode: off - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -137,12 +139,10 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # set_away_mode: on - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -153,9 +153,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) From eab510f4400425b6cfaa1b4b4099a8b5095f2f69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Feb 2025 08:47:01 -0600 Subject: [PATCH 353/410] Fix tplink child updates taking up to 60s (#137782) * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Revert "Fix tplink child updates taking up to 60s" This reverts commit 5cd20a120f772b8df96ec32890b071b22135895e. --- homeassistant/components/tplink/coordinator.py | 14 +++++++++++++- homeassistant/components/tplink/entity.py | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index d1b4694779d..fcd1335a77a 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -46,9 +46,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, + parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -95,6 +97,12 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() + if not self._update_children: + # If the children are not being updated, it means this is an + # IotStrip, and we need to tell the children to write state + # since the power state is provided by the parent. + for child_coordinator in self._child_coordinators.values(): + child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -132,7 +140,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, child, timedelta(seconds=60), self.config_entry + self.hass, + child, + timedelta(seconds=60), + self.config_entry, + parent_coordinator=self, ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 15c07655e69..7a0d811b30d 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,7 +151,13 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - await self.coordinator.async_request_refresh() + coordinator = self.coordinator + if coordinator.parent_coordinator: + # If there is a parent coordinator we need to refresh + # the parent as its what provides the power state data + # for the child entities. + coordinator = coordinator.parent_coordinator + await coordinator.async_request_refresh() return _async_wrap From 7bf81037c1ed1098b5401e3702ac4091c0811d74 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Feb 2025 15:48:31 +0100 Subject: [PATCH 354/410] Add Peblar charge switch (#137853) * Add Peblar charge switch * Update snapshots --- .../components/peblar/coordinator.py | 3 + homeassistant/components/peblar/icons.json | 3 + homeassistant/components/peblar/number.py | 150 +++++++++++------- homeassistant/components/peblar/strings.json | 3 + homeassistant/components/peblar/switch.py | 29 +++- .../peblar/snapshots/test_switch.ambr | 46 ++++++ tests/components/peblar/test_number.py | 108 +++++++++++-- tests/components/peblar/test_switch.py | 37 ++++- 8 files changed, 296 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 058f2aefb3b..36708b207c5 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -34,6 +34,7 @@ class PeblarRuntimeData: """Class to hold runtime data.""" data_coordinator: PeblarDataUpdateCoordinator + last_known_charging_limit = 6 system_information: PeblarSystemInformation user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator @@ -137,6 +138,8 @@ class PeblarVersionDataUpdateCoordinator( class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): """Class to manage fetching Peblar active data.""" + config_entry: PeblarConfigEntry + def __init__( self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi ) -> None: diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index 6244945077b..a954d112c4a 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -36,6 +36,9 @@ } }, "switch": { + "charge": { + "default": "mdi:ev-plug-type2" + }, "force_single_phase": { "default": "mdi:power-cycle" } diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index 1a7cec43295..0e929a63523 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -2,58 +2,27 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from peblar import PeblarApi - from homeassistant.components.number import ( NumberDeviceClass, - NumberEntity, NumberEntityDescription, + RestoreNumber, ) -from homeassistant.const import EntityCategory, UnitOfElectricCurrent -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EntityCategory, + UnitOfElectricCurrent, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import ( - PeblarConfigEntry, - PeblarData, - PeblarDataUpdateCoordinator, - PeblarRuntimeData, -) +from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator from .entity import PeblarEntity from .helpers import peblar_exception_handler PARALLEL_UPDATES = 1 -@dataclass(frozen=True, kw_only=True) -class PeblarNumberEntityDescription(NumberEntityDescription): - """Describe a Peblar number.""" - - native_max_value_fn: Callable[[PeblarRuntimeData], int] - set_value_fn: Callable[[PeblarApi, float], Awaitable[Any]] - value_fn: Callable[[PeblarData], int | None] - - -DESCRIPTIONS = [ - PeblarNumberEntityDescription( - key="charge_current_limit", - translation_key="charge_current_limit", - device_class=NumberDeviceClass.CURRENT, - entity_category=EntityCategory.CONFIG, - native_step=1, - native_min_value=6, - native_max_value_fn=lambda x: x.user_configuration_coordinator.data.user_defined_charge_limit_current, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - set_value_fn=lambda x, v: x.ev_interface(charge_current_limit=int(v) * 1000), - value_fn=lambda x: round(x.ev.charge_current_limit / 1000), - ), -] - - async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, @@ -61,42 +30,101 @@ async def async_setup_entry( ) -> None: """Set up Peblar number based on a config entry.""" async_add_entities( - PeblarNumberEntity( - entry=entry, - coordinator=entry.runtime_data.data_coordinator, - description=description, - ) - for description in DESCRIPTIONS + [ + PeblarChargeCurrentLimitNumberEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + ) + ] ) -class PeblarNumberEntity( +class PeblarChargeCurrentLimitNumberEntity( PeblarEntity[PeblarDataUpdateCoordinator], - NumberEntity, + RestoreNumber, ): - """Defines a Peblar number.""" + """Defines a Peblar charge current limit number. - entity_description: PeblarNumberEntityDescription + This entity is a little bit different from the other entities, any value + below 6 amps is ignored. It means the Peblar is not charging. + Peblar has assigned a dual functionality to the charge current limit + number, it is used to set the current charging value and to start/stop/pauze + the charging process. + """ + + _attr_device_class = NumberDeviceClass.CURRENT + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = 6 + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_translation_key = "charge_current_limit" def __init__( self, entry: PeblarConfigEntry, coordinator: PeblarDataUpdateCoordinator, - description: PeblarNumberEntityDescription, ) -> None: - """Initialize the Peblar entity.""" - super().__init__(entry=entry, coordinator=coordinator, description=description) - self._attr_native_max_value = description.native_max_value_fn( - entry.runtime_data + """Initialize the Peblar charge current limit entity.""" + super().__init__( + entry=entry, + coordinator=coordinator, + description=NumberEntityDescription(key="charge_current_limit"), ) + configuration = entry.runtime_data.user_configuration_coordinator.data + self._attr_native_max_value = configuration.user_defined_charge_limit_current - @property - def native_value(self) -> int | None: - """Return the number value.""" - return self.entity_description.value_fn(self.coordinator.data) + async def async_added_to_hass(self) -> None: + """Load the last known state when adding this entity.""" + if ( + (last_state := await self.async_get_last_state()) + and (last_number_data := await self.async_get_last_number_data()) + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and last_number_data.native_value + ): + self._attr_native_value = last_number_data.native_value + # Set the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + last_number_data.native_value + ) + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update. + + Ignore any update that provides a ampere value that is below the + minimum value (6 amps). It means the Peblar is currently not charging. + """ + if ( + current_charge_limit := round( + self.coordinator.data.ev.charge_current_limit / 1000 + ) + ) < 6: + return + self._attr_native_value = current_charge_limit + # Update the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = ( + current_charge_limit + ) + super()._handle_coordinator_update() @peblar_exception_handler async def async_set_native_value(self, value: float) -> None: - """Change to new number value.""" - await self.entity_description.set_value_fn(self.coordinator.api, value) + """Change the current charging value.""" + # If charging is currently disabled (below 6 amps), just set the value + # as the native value and the last known charging limit in the runtime + # data. So we can pick it up once charging gets enabled again. + if self.coordinator.data.ev.charge_current_limit < 6000: + self._attr_native_value = int(value) + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + value + ) + self.async_write_ha_state() + return + await self.coordinator.api.ev_interface(charge_current_limit=int(value) * 1000) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index a33667fa533..4a1500e54c5 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -153,6 +153,9 @@ } }, "switch": { + "charge": { + "name": "Charge" + }, "force_single_phase": { "name": "Force single phase" } diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py index e56c2fcdaec..74a42ddc47d 100644 --- a/homeassistant/components/peblar/switch.py +++ b/homeassistant/components/peblar/switch.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from peblar import PeblarApi +from peblar import PeblarEVInterface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -31,7 +31,19 @@ class PeblarSwitchEntityDescription(SwitchEntityDescription): has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True is_on_fn: Callable[[PeblarData], bool] - set_fn: Callable[[PeblarApi, bool], Awaitable[Any]] + set_fn: Callable[[PeblarDataUpdateCoordinator, bool], Awaitable[Any]] + + +def _async_peblar_charge( + coordinator: PeblarDataUpdateCoordinator, on: bool +) -> Awaitable[PeblarEVInterface]: + """Set the charge state.""" + charge_current_limit = 0 + if on: + charge_current_limit = ( + coordinator.config_entry.runtime_data.last_known_charging_limit * 1000 + ) + return coordinator.api.ev_interface(charge_current_limit=charge_current_limit) DESCRIPTIONS = [ @@ -44,7 +56,14 @@ DESCRIPTIONS = [ and x.user_configuration_coordinator.data.connected_phases > 1 ), is_on_fn=lambda x: x.ev.force_single_phase, - set_fn=lambda x, on: x.ev_interface(force_single_phase=on), + set_fn=lambda x, on: x.api.ev_interface(force_single_phase=on), + ), + PeblarSwitchEntityDescription( + key="charge", + translation_key="charge", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda x: (x.ev.charge_current_limit >= 6000), + set_fn=_async_peblar_charge, ), ] @@ -82,11 +101,11 @@ class PeblarSwitchEntity( @peblar_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_fn(self.coordinator.api, True) + await self.entity_description.set_fn(self.coordinator, True) await self.coordinator.async_request_refresh() @peblar_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.set_fn(self.coordinator.api, False) + await self.entity_description.set_fn(self.coordinator, False) await self.coordinator.async_request_refresh() diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr index 53829278593..426b48b6838 100644 --- a/tests/components/peblar/snapshots/test_switch.ambr +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_entities[switch][switch.peblar_ev_charger_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.peblar_ev_charger_charge', + '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': 'Charge', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge', + 'unique_id': '23-45-A4O-MOF_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch][switch.peblar_ev_charger_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Charge', + }), + 'context': , + 'entity_id': 'switch.peblar_ev_charger_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py index 57469fecbc6..fa49b6ab116 100644 --- a/tests/components/peblar/test_number.py +++ b/tests/components/peblar/test_number.py @@ -14,18 +14,19 @@ from homeassistant.components.number import ( from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform - -pytestmark = [ - pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True), - pytest.mark.usefixtures("init_integration"), -] +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -48,7 +49,8 @@ async def test_entities( assert entity_entry.device_id == device_entry.id -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value( hass: HomeAssistant, mock_peblar: MagicMock, @@ -73,6 +75,43 @@ async def test_number_set_value( mocked_method.mock_calls[0].assert_called_with({"charge_current_limit": 10}) +async def test_number_set_value_when_charging_is_suspended( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of setting the charging limit while charging is suspended.""" + entity_id = "number.peblar_ev_charger_charge_limit" + + # Suspend charging + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + # Test normal happy path number value change + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 0 + + # Check the state is reflected + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + + @pytest.mark.parametrize( ("error", "error_match", "translation_key", "translation_placeholders"), [ @@ -96,7 +135,8 @@ async def test_number_set_value( ), ], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value_communication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -128,6 +168,8 @@ async def test_number_set_value_communication_error( assert excinfo.value.translation_placeholders == translation_placeholders +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_number_set_value_authentication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -175,3 +217,51 @@ async def test_number_set_value_authentication_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("restore_state", "restore_native_value", "expected_state"), + [ + ("10", 10, "10"), + ("unknown", 10, "unknown"), + ("unavailable", 10, "unknown"), + ("10", None, "unknown"), + ], +) +async def test_restore_state( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + restore_state: str, + restore_native_value: int, + expected_state: str, +) -> None: + """Test restoring the number state.""" + EXTRA_STORED_DATA = { + "native_max_value": 16, + "native_min_value": 6, + "native_step": 1, + "native_unit_of_measurement": "A", + "native_value": restore_native_value, + } + mock_restore_cache_with_extra_data( + hass, + ( + ( + State("number.peblar_ev_charger_charge_limit", restore_state), + EXTRA_STORED_DATA, + ), + ), + ) + + # Adjust Peblar client to have an ignored value for the charging limit + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check if state is restored and value is set correctly + assert (state := hass.states.get("number.peblar_ev_charger_charge_limit")) + assert state.state == expected_state diff --git a/tests/components/peblar/test_switch.py b/tests/components/peblar/test_switch.py index 75deeb2d5d3..a7dab51eb3a 100644 --- a/tests/components/peblar/test_switch.py +++ b/tests/components/peblar/test_switch.py @@ -49,10 +49,32 @@ async def test_entities( @pytest.mark.parametrize( - ("service", "force_single_phase"), + ("service", "entity_id", "parameter", "parameter_value"), [ - (SERVICE_TURN_ON, True), - (SERVICE_TURN_OFF, False), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + True, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + False, + ), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 16, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 0, + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -60,10 +82,11 @@ async def test_switch( hass: HomeAssistant, mock_peblar: MagicMock, service: str, - force_single_phase: bool, + entity_id: str, + parameter: str, + parameter_value: bool | int, ) -> None: """Test the Peblar EV charger switches.""" - entity_id = "switch.peblar_ev_charger_force_single_phase" mocked_method = mock_peblar.rest_api.return_value.ev_interface mocked_method.reset_mock() @@ -76,9 +99,7 @@ async def test_switch( ) assert len(mocked_method.mock_calls) == 2 - mocked_method.mock_calls[0].assert_called_with( - {"force_single_phase": force_single_phase} - ) + mocked_method.mock_calls[0].assert_called_with({parameter: parameter_value}) @pytest.mark.parametrize( From 8ddae828f7d440b7b5b6f1baa6522d5924b61386 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:51:05 +0100 Subject: [PATCH 355/410] Fix DAB radio in Onkyo (#137852) --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 97a82fc8a1a..acb57e594b8 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -92,7 +92,7 @@ SUPPORT_ONKYO = ( DEFAULT_PLAYABLE_SOURCES = ( InputSource.from_meaning("FM"), InputSource.from_meaning("AM"), - InputSource.from_meaning("TUNER"), + InputSource.from_meaning("DAB"), ) ATTR_PRESET = "preset" From 58eb8e15983d8d460ffb5a7e8baddd04cfd12ff1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:52:30 +0100 Subject: [PATCH 356/410] Move ForkedDaapdUpdater to separate module (#137654) * Move ForkedDaapdUpdater to separate module * Remove moved constants --- .../components/forked_daapd/coordinator.py | 142 ++++++++++++++++++ .../components/forked_daapd/media_player.py | 124 +-------------- 2 files changed, 143 insertions(+), 123 deletions(-) create mode 100644 homeassistant/components/forked_daapd/coordinator.py diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py new file mode 100644 index 00000000000..7a03a9075ed --- /dev/null +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -0,0 +1,142 @@ +"""Support forked_daapd media player.""" + +from __future__ import annotations + +import asyncio +import logging + +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + SIGNAL_ADD_ZONES, + SIGNAL_UPDATE_DATABASE, + SIGNAL_UPDATE_MASTER, + SIGNAL_UPDATE_OUTPUTS, + SIGNAL_UPDATE_PLAYER, + SIGNAL_UPDATE_QUEUE, +) + +_LOGGER = logging.getLogger(__name__) + +WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] +WEBSOCKET_RECONNECT_TIME = 30 # seconds + + +class ForkedDaapdUpdater: + """Manage updates for the forked-daapd device.""" + + def __init__(self, hass, api, entry_id): + """Initialize.""" + self.hass = hass + self._api = api + self.websocket_handler = None + self._all_output_ids = set() + self._entry_id = entry_id + + async def async_init(self): + """Perform async portion of class initialization.""" + if not (server_config := await self._api.get_request("config")): + raise PlatformNotReady + if websocket_port := server_config.get("websocket_port"): + self.websocket_handler = asyncio.create_task( + self._api.start_websocket_handler( + websocket_port, + WS_NOTIFY_EVENT_TYPES, + self._update, + WEBSOCKET_RECONNECT_TIME, + self._disconnected_callback, + ) + ) + else: + _LOGGER.error("Invalid websocket port") + + async def _disconnected_callback(self): + """Send update signals when the websocket gets disconnected.""" + async_dispatcher_send( + self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False + ) + async_dispatcher_send( + self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), [] + ) + + async def _update(self, update_types): + """Private update method.""" + update_types = set(update_types) + update_events = {} + _LOGGER.debug("Updating %s", update_types) + if ( + "queue" in update_types + ): # update queue, queue before player for async_play_media + if queue := await self._api.get_request("queue"): + update_events["queue"] = asyncio.Event() + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_QUEUE.format(self._entry_id), + queue, + update_events["queue"], + ) + # order of below don't matter + if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs + if outputs := await self._api.get_request("outputs"): + outputs = outputs["outputs"] + update_events["outputs"] = ( + asyncio.Event() + ) # only for master, zones should ignore + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), + outputs, + update_events["outputs"], + ) + self._add_zones(outputs) + if not {"database"}.isdisjoint(update_types): + pipes, playlists = await asyncio.gather( + self._api.get_pipes(), self._api.get_playlists() + ) + update_events["database"] = asyncio.Event() + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_DATABASE.format(self._entry_id), + pipes, + playlists, + update_events["database"], + ) + if not {"update", "config"}.isdisjoint(update_types): # not supported + _LOGGER.debug("update/config notifications neither requested nor supported") + if not {"player", "options", "volume"}.isdisjoint( + update_types + ): # update player + if player := await self._api.get_request("player"): + update_events["player"] = asyncio.Event() + if update_events.get("queue"): + await update_events[ + "queue" + ].wait() # make sure queue done before player for async_play_media + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_PLAYER.format(self._entry_id), + player, + update_events["player"], + ) + if update_events: + await asyncio.wait( + [asyncio.create_task(event.wait()) for event in update_events.values()] + ) # make sure callbacks done before update + async_dispatcher_send( + self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True + ) + + def _add_zones(self, outputs): + outputs_to_add = [] + for output in outputs: + if output["id"] not in self._all_output_ids: + self._all_output_ids.add(output["id"]) + outputs_to_add.append(output) + if outputs_to_add: + async_dispatcher_send( + self.hass, + SIGNAL_ADD_ZONES.format(self._entry_id), + self._api, + outputs_to_add, + ) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 0116cc57e7b..8e61df3de45 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -31,7 +31,6 @@ from homeassistant.components.spotify import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -75,12 +74,10 @@ from .const import ( SUPPORTED_FEATURES_ZONE, TTS_TIMEOUT, ) +from .coordinator import ForkedDaapdUpdater _LOGGER = logging.getLogger(__name__) -WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] -WEBSOCKET_RECONNECT_TIME = 30 # seconds - async def async_setup_entry( hass: HomeAssistant, @@ -897,122 +894,3 @@ class ForkedDaapdMaster(MediaPlayerEntity): if url := result.get("artwork_url"): return await self._async_fetch_image(self.api.full_url(url)) return None, None - - -class ForkedDaapdUpdater: - """Manage updates for the forked-daapd device.""" - - def __init__(self, hass, api, entry_id): - """Initialize.""" - self.hass = hass - self._api = api - self.websocket_handler = None - self._all_output_ids = set() - self._entry_id = entry_id - - async def async_init(self): - """Perform async portion of class initialization.""" - if not (server_config := await self._api.get_request("config")): - raise PlatformNotReady - if websocket_port := server_config.get("websocket_port"): - self.websocket_handler = asyncio.create_task( - self._api.start_websocket_handler( - websocket_port, - WS_NOTIFY_EVENT_TYPES, - self._update, - WEBSOCKET_RECONNECT_TIME, - self._disconnected_callback, - ) - ) - else: - _LOGGER.error("Invalid websocket port") - - async def _disconnected_callback(self): - """Send update signals when the websocket gets disconnected.""" - async_dispatcher_send( - self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False - ) - async_dispatcher_send( - self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), [] - ) - - async def _update(self, update_types): - """Private update method.""" - update_types = set(update_types) - update_events = {} - _LOGGER.debug("Updating %s", update_types) - if ( - "queue" in update_types - ): # update queue, queue before player for async_play_media - if queue := await self._api.get_request("queue"): - update_events["queue"] = asyncio.Event() - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_QUEUE.format(self._entry_id), - queue, - update_events["queue"], - ) - # order of below don't matter - if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs - if outputs := await self._api.get_request("outputs"): - outputs = outputs["outputs"] - update_events["outputs"] = ( - asyncio.Event() - ) # only for master, zones should ignore - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), - outputs, - update_events["outputs"], - ) - self._add_zones(outputs) - if not {"database"}.isdisjoint(update_types): - pipes, playlists = await asyncio.gather( - self._api.get_pipes(), self._api.get_playlists() - ) - update_events["database"] = asyncio.Event() - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_DATABASE.format(self._entry_id), - pipes, - playlists, - update_events["database"], - ) - if not {"update", "config"}.isdisjoint(update_types): # not supported - _LOGGER.debug("update/config notifications neither requested nor supported") - if not {"player", "options", "volume"}.isdisjoint( - update_types - ): # update player - if player := await self._api.get_request("player"): - update_events["player"] = asyncio.Event() - if update_events.get("queue"): - await update_events[ - "queue" - ].wait() # make sure queue done before player for async_play_media - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_PLAYER.format(self._entry_id), - player, - update_events["player"], - ) - if update_events: - await asyncio.wait( - [asyncio.create_task(event.wait()) for event in update_events.values()] - ) # make sure callbacks done before update - async_dispatcher_send( - self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True - ) - - def _add_zones(self, outputs): - outputs_to_add = [] - for output in outputs: - if output["id"] not in self._all_output_ids: - self._all_output_ids.add(output["id"]) - outputs_to_add.append(output) - if outputs_to_add: - async_dispatcher_send( - self.hass, - SIGNAL_ADD_ZONES.format(self._entry_id), - self._api, - outputs_to_add, - ) From 303ab750ab6f20c0497b6f62e96bca7702d334e4 Mon Sep 17 00:00:00 2001 From: jdelaney72 <20731268+jdelaney72@users.noreply.github.com> Date: Sat, 8 Feb 2025 06:56:54 -0800 Subject: [PATCH 357/410] Bump noaa-coops to version 0.4.0 (#137777) Bump noaa-coops 0.4.0 https://github.com/GClunies/noaa_coops/compare/2cd2fca..0972373 --- homeassistant/components/noaa_tides/manifest.json | 2 +- homeassistant/components/noaa_tides/sensor.py | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json index 8cc81857770..02a189883bc 100644 --- a/homeassistant/components/noaa_tides/manifest.json +++ b/homeassistant/components/noaa_tides/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["noaa_coops"], "quality_scale": "legacy", - "requirements": ["noaa-coops==0.1.9"] + "requirements": ["noaa-coops==0.4.0"] } diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index f6ec9dc4bf2..0af2c340960 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -169,8 +169,8 @@ class NOAATidesAndCurrentsSensor(SensorEntity): api_data = df_predictions.head() self.data = NOAATidesData( time_stamp=list(api_data.index), - hi_lo=list(api_data["hi_lo"].values), - predicted_wl=list(api_data["predicted_wl"].values), + hi_lo=list(api_data["type"].values), + predicted_wl=list(api_data["v"].values), ) _LOGGER.debug("Data = %s", api_data) _LOGGER.debug( diff --git a/requirements_all.txt b/requirements_all.txt index 13abe012fcc..b00c07f01b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1501,7 +1501,7 @@ nice-go==1.0.1 niluclient==0.1.2 # homeassistant.components.noaa_tides -noaa-coops==0.1.9 +noaa-coops==0.4.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 7f6855045a8a1ca921f82f66a396a23d04e44000 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:02:24 +0100 Subject: [PATCH 358/410] Bump plugwise to v1.7.1 and adapt (#137599) * Bump plugwise v1.7.1 * Refresh test-fixtures * Adapt integration code * Adapt test code * Fixes * Save updated snapshot * Ruff fixes * More ruff fixes --- homeassistant/components/plugwise/__init__.py | 2 +- .../components/plugwise/binary_sensor.py | 9 +- homeassistant/components/plugwise/button.py | 7 +- homeassistant/components/plugwise/climate.py | 20 +- homeassistant/components/plugwise/const.py | 1 - .../components/plugwise/coordinator.py | 30 +- .../components/plugwise/diagnostics.py | 5 +- homeassistant/components/plugwise/entity.py | 12 +- .../components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/number.py | 2 +- homeassistant/components/plugwise/select.py | 2 +- homeassistant/components/plugwise/sensor.py | 2 +- homeassistant/components/plugwise/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/conftest.py | 124 +- .../fixtures/anna_heatpump_heating/data.json | 97 ++ .../plugwise/fixtures/legacy_anna/data.json | 60 + .../fixtures/m_adam_cooling/data.json | 203 +++ .../fixtures/m_adam_heating/data.json | 202 +++ .../plugwise/fixtures/m_adam_jip/data.json | 370 +++++ .../data.json | 584 ++++++++ .../m_anna_heatpump_cooling/data.json | 97 ++ .../fixtures/m_anna_heatpump_idle/data.json | 97 ++ .../fixtures/p1v4_442_single/data.json | 43 + .../fixtures/p1v4_442_triple/data.json | 56 + .../plugwise/fixtures/smile_p1_v2/data.json | 34 + .../plugwise/fixtures/stretch_v31/data.json | 136 ++ .../plugwise/snapshots/test_diagnostics.ambr | 1222 ++++++++--------- .../components/plugwise/test_binary_sensor.py | 2 + tests/components/plugwise/test_climate.py | 40 +- tests/components/plugwise/test_init.py | 12 +- tests/components/plugwise/test_number.py | 3 + tests/components/plugwise/test_select.py | 2 + tests/components/plugwise/test_sensor.py | 1 + 35 files changed, 2738 insertions(+), 747 deletions(-) create mode 100644 tests/components/plugwise/fixtures/anna_heatpump_heating/data.json create mode 100644 tests/components/plugwise/fixtures/legacy_anna/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_cooling/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_heating/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_jip/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json create mode 100644 tests/components/plugwise/fixtures/p1v4_442_single/data.json create mode 100644 tests/components/plugwise/fixtures/p1v4_442_triple/data.json create mode 100644 tests/components/plugwise/fixtures/smile_p1_v2/data.json create mode 100644 tests/components/plugwise/fixtures/stretch_v31/data.json diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index a100103b029..f1cc7c6c11d 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -82,7 +82,7 @@ def migrate_sensor_entities( # Migrating opentherm_outdoor_temperature # to opentherm_outdoor_air_temperature sensor - for device_id, device in coordinator.data.devices.items(): + for device_id, device in coordinator.data.items(): if device["dev_class"] != "heater_central": continue diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 539fa243d6c..a4c6e051c78 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -100,11 +100,7 @@ async def async_setup_entry( async_add_entities( PlugwiseBinarySensorEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if ( - binary_sensors := coordinator.data.devices[device_id].get( - "binary_sensors" - ) - ) + if (binary_sensors := coordinator.data[device_id].get("binary_sensors")) for description in BINARY_SENSORS if description.key in binary_sensors ) @@ -141,7 +137,8 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): return None attrs: dict[str, list[str]] = {f"{severity}_msg": [] for severity in SEVERITIES} - if notify := self.coordinator.data.gateway["notifications"]: + gateway_id = self.coordinator.api.gateway_id + if notify := self.coordinator.data[gateway_id]["notifications"]: for details in notify.values(): for msg_type, msg in details.items(): msg_type = msg_type.lower() diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index 8a05ede3496..139b358162c 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry -from .const import GATEWAY_ID, REBOOT +from .const import REBOOT from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -24,11 +24,10 @@ async def async_setup_entry( """Set up the Plugwise buttons from a ConfigEntry.""" coordinator = entry.runtime_data - gateway = coordinator.data.gateway async_add_entities( PlugwiseButtonEntity(coordinator, device_id) - for device_id in coordinator.data.devices - if device_id == gateway[GATEWAY_ID] and REBOOT in gateway + for device_id in coordinator.data + if device_id == coordinator.api.gateway_id and coordinator.api.reboot ) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 3caed1e7bc2..7abdfcfde54 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -41,18 +41,17 @@ async def async_setup_entry( if not coordinator.new_devices: return - if coordinator.data.gateway["smile_name"] == "Adam": + if coordinator.api.smile_name == "Adam": async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] == "climate" + if coordinator.data[device_id]["dev_class"] == "climate" ) else: async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] - in MASTER_THERMOSTATS + if coordinator.data[device_id]["dev_class"] in MASTER_THERMOSTATS ) _add_entities() @@ -77,10 +76,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_unique_id = f"{device_id}-climate" - self._devices = coordinator.data.devices - self._gateway = coordinator.data.gateway - gateway_id: str = self._gateway["gateway_id"] - self._gateway_data = self._devices[gateway_id] + gateway_id: str = coordinator.api.gateway_id + self._gateway_data = coordinator.data[gateway_id] self._location = device_id if (location := self.device.get("location")) is not None: @@ -88,7 +85,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam": + if ( + self.coordinator.api.cooling_present + and coordinator.api.smile_name != "Adam" + ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -170,7 +170,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if "available_schedules" in self.device: hvac_modes.append(HVACMode.AUTO) - if self._gateway["cooling_present"]: + if self.coordinator.api.cooling_present: if "regulation_modes" in self._gateway_data: if self._gateway_data["select_regulation_mode"] == "cooling": hvac_modes.append(HVACMode.COOL) diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 5e4dea5586b..176ae39b1ba 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -17,7 +17,6 @@ FLOW_SMILE: Final = "smile (Adam/Anna/P1)" FLOW_STRETCH: Final = "stretch (Stretch)" FLOW_TYPE: Final = "flow_type" GATEWAY: Final = "gateway" -GATEWAY_ID: Final = "gateway_id" LOCATION: Final = "location" PW_TYPE: Final = "plugwise_type" REBOOT: Final = "reboot" diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 7ac0cc21c51..9a85ae2a5df 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta from packaging.version import Version -from plugwise import PlugwiseData, Smile +from plugwise import GwEntityData, Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -22,10 +22,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, GATEWAY_ID, LOGGER +from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, LOGGER -class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): +class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]): """Class to manage fetching Plugwise data from single endpoint.""" _connected: bool = False @@ -63,10 +63,8 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): """Connect to the Plugwise Smile.""" version = await self.api.connect() self._connected = isinstance(version, Version) - if self._connected: - self.api.get_all_gateway_entities() - async def _async_update_data(self) -> PlugwiseData: + async def _async_update_data(self) -> dict[str, GwEntityData]: """Fetch data from Plugwise.""" try: if not self._connected: @@ -101,26 +99,28 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): self._async_add_remove_devices(data, self.config_entry) return data - def _async_add_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: + def _async_add_remove_devices( + self, data: dict[str, GwEntityData], entry: ConfigEntry + ) -> None: """Add new Plugwise devices, remove non-existing devices.""" # Check for new or removed devices - self.new_devices = set(data.devices) - self._current_devices - removed_devices = self._current_devices - set(data.devices) - self._current_devices = set(data.devices) + self.new_devices = set(data) - self._current_devices + removed_devices = self._current_devices - set(data) + self._current_devices = set(data) if removed_devices: self._async_remove_devices(data, entry) - def _async_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: + def _async_remove_devices( + self, data: dict[str, GwEntityData], entry: ConfigEntry + ) -> None: """Clean registries when removed devices found.""" device_reg = dr.async_get(self.hass) device_list = dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ) # First find the Plugwise via_device - gateway_device = device_reg.async_get_device( - {(DOMAIN, data.gateway[GATEWAY_ID])} - ) + gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)}) assert gateway_device is not None via_device_id = gateway_device.id @@ -130,7 +130,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): if identifier[0] == DOMAIN: if ( device_entry.via_device_id == via_device_id - and identifier[1] not in data.devices + and identifier[1] not in data ): device_reg.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index 47ff7d1a9fb..a576e60dbe1 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -14,7 +14,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data - return { - "devices": coordinator.data.devices, - "gateway": coordinator.data.gateway, - } + return coordinator.data diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 3f63abaff43..39838c38fde 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -34,7 +34,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): if entry := self.coordinator.config_entry: configuration_url = f"http://{entry.data[CONF_HOST]}" - data = coordinator.data.devices[device_id] + data = coordinator.data[device_id] connections = set() if mac := data.get("mac_address"): connections.add((CONNECTION_NETWORK_MAC, mac)) @@ -48,18 +48,18 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): manufacturer=data.get("vendor"), model=data.get("model"), model_id=data.get("model_id"), - name=coordinator.data.gateway["smile_name"], + name=coordinator.api.smile_name, sw_version=data.get("firmware"), hw_version=data.get("hardware"), ) - if device_id != coordinator.data.gateway["gateway_id"]: + if device_id != coordinator.api.gateway_id: self._attr_device_info.update( { ATTR_NAME: data.get("name"), ATTR_VIA_DEVICE: ( DOMAIN, - str(self.coordinator.data.gateway["gateway_id"]), + str(self.coordinator.api.gateway_id), ), } ) @@ -68,7 +68,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): def available(self) -> bool: """Return if entity is available.""" return ( - self._dev_id in self.coordinator.data.devices + self._dev_id in self.coordinator.data and ("available" not in self.device or self.device["available"] is True) and super().available ) @@ -76,4 +76,4 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): @property def device(self) -> GwEntityData: """Return data for this device.""" - return self.coordinator.data.devices[self._dev_id] + return self.coordinator.data[self._dev_id] diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index f7bd646f801..983ff10b0a6 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.6.4"], + "requirements": ["plugwise==1.7.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 1d0b1382c24..2de49f17d4a 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -73,7 +73,7 @@ async def async_setup_entry( PlugwiseNumberEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in NUMBER_TYPES - if description.key in coordinator.data.devices[device_id] + if description.key in coordinator.data[device_id] ) _add_entities() diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index ff268d8eded..307091f0ff9 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -71,7 +71,7 @@ async def async_setup_entry( PlugwiseSelectEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in SELECT_TYPES - if description.options_key in coordinator.data.devices[device_id] + if description.options_key in coordinator.data[device_id] ) _add_entities() diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 14b42682376..8b630c39878 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -420,7 +420,7 @@ async def async_setup_entry( async_add_entities( PlugwiseSensorEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if (sensors := coordinator.data.devices[device_id].get("sensors")) + if (sensors := coordinator.data[device_id].get("sensors")) for description in SENSORS if description.key in sensors ) diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index ea6d6f18b7f..86496a4311e 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -72,7 +72,7 @@ async def async_setup_entry( async_add_entities( PlugwiseSwitchEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if (switches := coordinator.data.devices[device_id].get("switches")) + if (switches := coordinator.data[device_id].get("switches")) for description in SWITCHES if description.key in switches ) diff --git a/requirements_all.txt b/requirements_all.txt index b00c07f01b7..2d07a3aa061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1663,7 +1663,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.4 +plugwise==1.7.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 580efd88992..c18706cbef0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.4 +plugwise==1.7.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index 92ed42aa03a..e0a61106101 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from packaging.version import Version -from plugwise import PlugwiseData import pytest from homeassistant.components.plugwise.const import DOMAIN @@ -30,6 +29,15 @@ def _read_json(environment: str, call: str) -> dict[str, Any]: return json.loads(fixture) +@pytest.fixture +def cooling_present(request: pytest.FixtureRequest) -> str: + """Pass the cooling_present boolean. + + Used with fixtures that require parametrization of the cooling capability. + """ + return request.param + + @pytest.fixture def chosen_env(request: pytest.FixtureRequest) -> str: """Pass the chosen_env string. @@ -48,6 +56,24 @@ def gateway_id(request: pytest.FixtureRequest) -> str: return request.param +@pytest.fixture +def heater_id(request: pytest.FixtureRequest) -> str: + """Pass the heater_idstring. + + Used with fixtures that require parametrization of the heater_id. + """ + return request.param + + +@pytest.fixture +def reboot(request: pytest.FixtureRequest) -> str: + """Pass the reboot boolean. + + Used with fixtures that require parametrization of the reboot capability. + """ + return request.param + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -82,11 +108,14 @@ def mock_smile_config_flow() -> Generator[MagicMock]: autospec=True, ) as smile_mock: smile = smile_mock.return_value + + smile.connect.return_value = Version("4.3.2") smile.smile_hostname = "smile12345" smile.smile_model = "Test Model" smile.smile_model_id = "Test Model ID" smile.smile_name = "Test Smile Name" - smile.connect.return_value = Version("4.3.2") + smile.smile_version = "4.3.2" + yield smile @@ -94,7 +123,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_multiple_devices_per_zone" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True @@ -106,43 +135,45 @@ def mock_smile_adam() -> Generator[MagicMock]: ): smile = smile_mock.return_value + smile.async_update.return_value = data + smile.cooling_present = False + smile.connect.return_value = Version("3.0.15") smile.gateway_id = "fe799307f1624099878210aa0b9f1475" smile.heater_id = "90986d591dcd426cae3ec3e8111ff730" - smile.smile_version = "3.0.15" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.0.15") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "3.0.15" yield smile @pytest.fixture -def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: +def mock_smile_adam_heat_cool( + chosen_env: str, cooling_present: bool +) -> Generator[MagicMock]: """Create a special base Mock Adam type for testing with different datasets.""" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.async_update.return_value = data smile.connect.return_value = Version("3.6.4") + smile.cooling_present = cooling_present smile.gateway_id = "da224107914542988a88561b4452b0f6" smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" + smile.smile_type = "thermostat" + smile.smile_version = "3.6.4" yield smile @@ -151,49 +182,49 @@ def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: def mock_smile_adam_jip() -> Generator[MagicMock]: """Create a Mock adam-jip type for testing exceptions.""" chosen_env = "m_adam_jip" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.2.8") + smile.cooling_present = False smile.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" smile.heater_id = "e4684553153b44afbef2200885f379dc" - smile.smile_version = "3.2.8" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.2.8") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "3.2.8" yield smile @pytest.fixture -def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: +def mock_smile_anna(chosen_env: str, cooling_present: bool) -> Generator[MagicMock]: """Create a Mock Anna type for testing.""" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.async_update.return_value = data smile.connect.return_value = Version("4.0.15") + smile.cooling_present = cooling_present smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" + smile.smile_type = "thermostat" + smile.smile_version = "4.0.15" yield smile @@ -201,18 +232,17 @@ def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: @pytest.fixture def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: """Create a base Mock P1 type for testing with different datasets and gateway-ids.""" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.async_update.return_value = data smile.connect.return_value = Version("4.4.2") smile.gateway_id = gateway_id smile.heater_id = None + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile" @@ -227,24 +257,23 @@ def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: def mock_smile_legacy_anna() -> Generator[MagicMock]: """Create a Mock legacy Anna environment for testing exceptions.""" chosen_env = "legacy_anna" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("1.8.22") smile.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" smile.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" - smile.smile_version = "1.8.22" - smile.smile_type = "thermostat" + smile.reboot = False smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = None smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("1.8.22") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "1.8.22" yield smile @@ -253,24 +282,23 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.1.11") smile.gateway_id = "259882df3c05415b99c2d962534ce820" smile.heater_id = None - smile.smile_version = "3.1.11" - smile.smile_type = "stretch" + smile.reboot = False smile.smile_hostname = "stretch98765" smile.smile_model = "Gateway" smile.smile_model_id = None smile.smile_name = "Stretch" - smile.connect.return_value = Version("3.1.11") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "stretch" + smile.smile_version = "3.1.11" yield smile diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json new file mode 100644 index 00000000000..ab6bdf08e95 --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 20.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": false, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": true, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 35.0, + "modulation_level": 52, + "outdoor_air_temperature": 3.0, + "return_temperature": 25.1, + "water_pressure": 1.57, + "water_temperature": 29.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 21.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 19.3 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/legacy_anna/data.json b/tests/components/plugwise/fixtures/legacy_anna/data.json new file mode 100644 index 00000000000..cc7e66fb174 --- /dev/null +++ b/tests/components/plugwise/fixtures/legacy_anna/data.json @@ -0,0 +1,60 @@ +{ + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "1.8.22", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise" + }, + "04e4cbfe7f4340f090f85ec3b9e6a950": { + "binary_sensors": { + "flame_state": true, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "maximum_boiler_temperature": { + "lower_bound": 50.0, + "resolution": 1.0, + "setpoint": 50.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 51.2, + "intended_boiler_temperature": 17.0, + "modulation_level": 0.0, + "return_temperature": 21.7, + "water_pressure": 1.2, + "water_temperature": 23.6 + }, + "vendor": "Bosch Thermotechniek B.V." + }, + "0d266432d64443e283b5d708ae98b455": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "heating", + "dev_class": "thermostat", + "firmware": "2017-03-13T11:54:58+01:00", + "hardware": "6539-1301-500", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], + "sensors": { + "illuminance": 150.8, + "setpoint": 20.5, + "temperature": 20.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/data.json b/tests/components/plugwise/fixtures/m_adam_cooling/data.json new file mode 100644 index 00000000000..51f19ca3c03 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/data.json @@ -0,0 +1,203 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 17.5, + "water_temperature": 19.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "setpoint": 18.0, + "temperature": 21.6, + "temperature_difference": -0.2, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FF5EE" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "available": true, + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 23.5, + "temperature": 25.8 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "012345679891", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": [ + "bleeding_hot", + "bleeding_cold", + "off", + "heating", + "cooling" + ], + "select_gateway_mode": "full", + "select_regulation_mode": "cooling", + "sensors": { + "outdoor_temperature": 29.65 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5A168D" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": true + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 14, + "setpoint": 23.5, + "temperature": 23.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C869B61" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "cool", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 25.8 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 23.5, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 23.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 25.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_heating/data.json b/tests/components/plugwise/fixtures/m_adam_heating/data.json new file mode 100644 index 00000000000..b10ff8ec2a8 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/data.json @@ -0,0 +1,202 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 38.1, + "water_temperature": 37.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "setpoint": 18.0, + "temperature": 18.6, + "temperature_difference": -0.2, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FF5EE" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "available": true, + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 20.0, + "temperature": 19.1 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "012345679891", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], + "select_gateway_mode": "full", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": -1.25 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5A168D" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": true + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 14, + "setpoint": 15.0, + "temperature": 17.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C869B61" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "heat", + "control_state": "preheating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 17.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json new file mode 100644 index 00000000000..8de57910f66 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -0,0 +1,370 @@ +{ + "06aecb3d00354375924f50c47af36bd2": { + "active_preset": "no_frost", + "climate_mode": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Slaapkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 24.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], + "secondary": ["356b65335e274d769c338223e7af9c33"] + }, + "vendor": "Plugwise" + }, + "13228dab8ce04617af318a2888b3c548": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 27.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.01, + "setpoint": 9.0, + "upper_bound": 30.0 + }, + "thermostats": { + "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], + "secondary": ["833de10f269c4deab58fb9df69901b4e"] + }, + "vendor": "Plugwise" + }, + "1346fbd8498d4dbcab7e18d51b771f3d": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Lisa", + "model_id": "158-01", + "name": "Slaapkamer", + "sensors": { + "battery": 92, + "setpoint": 13.0, + "temperature": 24.2 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "1da4d325838e4ad8aac12177214505c9": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Logeerkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.8, + "temperature_difference": 2.0, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "356b65335e274d769c338223e7af9c33": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Slaapkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 24.2, + "temperature_difference": 1.7, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "457ce8414de24596a2d5e7dbc9c7682f": { + "available": true, + "dev_class": "zz_misc_plug", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "model": "Aqara Smart Plug", + "model_id": "lumi.plug.maeu01", + "name": "Plug", + "sensors": { + "electricity_consumed_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": false + }, + "vendor": "LUMI", + "zigbee_mac_address": "ABCD012345670A06" + }, + "6f3e9d7084214c21b9dfa46f6eeb8700": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Lisa", + "model_id": "158-01", + "name": "Kinderkamer", + "sensors": { + "battery": 79, + "setpoint": 13.0, + "temperature": 30.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "833de10f269c4deab58fb9df69901b4e": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Woonkamer", + "sensors": { + "setpoint": 9.0, + "temperature": 24.0, + "temperature_difference": 1.8, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "a6abc6a129ee499c88a4d420cc413b47": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Lisa", + "model_id": "158-01", + "name": "Logeerkamer", + "sensors": { + "battery": 80, + "setpoint": 13.0, + "temperature": 30.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "b5c2386c6f6342669e50fe49dd05b188": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.2.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "select_gateway_mode": "full", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 24.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "d27aede973b54be484f6842d1b2802ad": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Kinderkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], + "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] + }, + "vendor": "Plugwise" + }, + "d4496250d0e942cfa7aea3476e9070d5": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Kinderkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.7, + "temperature_difference": 1.9, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "d58fec52899f4f1c92e4f8fad6d8c48c": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Logeerkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["a6abc6a129ee499c88a4d420cc413b47"], + "secondary": ["1da4d325838e4ad8aac12177214505c9"] + }, + "vendor": "Plugwise" + }, + "e4684553153b44afbef2200885f379dc": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 20.0, + "resolution": 0.01, + "setpoint": 90.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "model_id": "10.20", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "modulation_level": 0.0, + "return_temperature": 37.1, + "water_pressure": 1.4, + "water_temperature": 37.3 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Remeha B.V." + }, + "f61f1a2535f54f52ad006a3d18e459ca": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermometer", + "firmware": "2020-09-01T02:00:00+02:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Jip", + "model_id": "168-01", + "name": "Woonkamer", + "sensors": { + "battery": 100, + "humidity": 56.2, + "setpoint": 9.0, + "temperature": 27.4 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json new file mode 100644 index 00000000000..7c38b1b2197 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -0,0 +1,584 @@ +{ + "02cf28bfec924855854c544690a609ef": { + "available": true, + "dev_class": "vcr_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "NVR", + "sensors": { + "electricity_consumed": 34.0, + "electricity_consumed_interval": 9.15, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A15" + }, + "08963fec7c53423ca5680aa4cb502c63": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": { + "temperature": 18.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": [ + "f1fee6043d3642a9b0a65297455f008e", + "680423ff840043738f42cc7f1ff97a36" + ], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "12493538af164a409c6a1c79e38afe1c": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 16.5 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["df4a4a8169904cdb9c03d61a21f42140"], + "secondary": ["a2c3583e0a6349358998b760cea82d2a"] + }, + "vendor": "Plugwise" + }, + "21f2b542c49845e6bb416884c55778d6": { + "available": true, + "dev_class": "game_console_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Playstation Smart Plug", + "sensors": { + "electricity_consumed": 84.1, + "electricity_consumed_interval": 8.6, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12" + }, + "446ac08dd04d4eff8ac57489757b7314": { + "active_preset": "no_frost", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 15.6 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["e7693eb9582644e5b865dba8d4447cf1"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "4a810418d5394b3f82727340b91ba740": { + "available": true, + "dev_class": "router_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "USG Smart Plug", + "sensors": { + "electricity_consumed": 8.5, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16" + }, + "675416a629f343c495449970e2ca37b5": { + "available": true, + "dev_class": "router_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Ziggo Modem", + "sensors": { + "electricity_consumed": 12.2, + "electricity_consumed_interval": 2.97, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "680423ff840043738f42cc7f1ff97a36": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Thermostatic Radiator Badkamer 1", + "sensors": { + "battery": 51, + "setpoint": 14.0, + "temperature": 19.1, + "temperature_difference": -0.4, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A17" + }, + "6a3bf693d05e48e0b460c815a4fdd09d": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "82fa13f017d240daa0d0ea1775420f24", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Thermostat Jessie", + "sensors": { + "battery": 37, + "setpoint": 15.0, + "temperature": 17.2 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "78d1126fc4c743db81b61c20e88342a7": { + "available": true, + "dev_class": "central_heating_pump_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Plug", + "model_id": "160-01", + "name": "CV Pomp", + "sensors": { + "electricity_consumed": 35.6, + "electricity_consumed_interval": 7.37, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "82fa13f017d240daa0d0ea1775420f24": { + "active_preset": "asleep", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": { + "temperature": 17.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], + "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] + }, + "vendor": "Plugwise" + }, + "90986d591dcd426cae3ec3e8111ff730": { + "binary_sensors": { + "heating_state": true + }, + "dev_class": "heater_central", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "model": "Unknown", + "name": "OnOff", + "sensors": { + "intended_boiler_temperature": 70.0, + "modulation_level": 1, + "water_temperature": 70.0 + } + }, + "a28f588dc4a049a483fd03a30361ad3a": { + "available": true, + "dev_class": "settop_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Fibaro HC2", + "sensors": { + "electricity_consumed": 12.5, + "electricity_consumed_interval": 3.8, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A13" + }, + "a2c3583e0a6349358998b760cea82d2a": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Bios Cv Thermostatic Radiator ", + "sensors": { + "battery": 62, + "setpoint": 13.0, + "temperature": 17.2, + "temperature_difference": -0.2, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "b310b72a0e354bfab43089919b9a88bf": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Floor kraan", + "sensors": { + "setpoint": 21.5, + "temperature": 26.0, + "temperature_difference": 3.5, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "b59bcebaf94b499ea7d46e4a66fb62d8": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-08-02T02:00:00+02:00", + "hardware": "255", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Lisa WK", + "sensors": { + "battery": 34, + "setpoint": 21.5, + "temperature": 20.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "c50f167537524366a5af7aa3942feb1e": { + "active_preset": "home", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": { + "electricity_consumed": 35.6, + "electricity_produced": 0.0, + "temperature": 20.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 21.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], + "secondary": ["b310b72a0e354bfab43089919b9a88bf"] + }, + "vendor": "Plugwise" + }, + "cd0ddb54ef694e11ac18ed1cbce5dbbd": { + "available": true, + "dev_class": "vcr_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "NAS", + "sensors": { + "electricity_consumed": 16.5, + "electricity_consumed_interval": 0.5, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14" + }, + "d3da73bde12a47d5a6b8f9dad971f2ec": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "82fa13f017d240daa0d0ea1775420f24", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Thermostatic Radiator Jessie", + "sensors": { + "battery": 62, + "setpoint": 15.0, + "temperature": 17.1, + "temperature_difference": 0.1, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10" + }, + "df4a4a8169904cdb9c03d61a21f42140": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Lisa Bios", + "sensors": { + "battery": 67, + "setpoint": 13.0, + "temperature": 16.5 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A06" + }, + "e7693eb9582644e5b865dba8d4447cf1": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "446ac08dd04d4eff8ac57489757b7314", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "CV Kraan Garage", + "sensors": { + "battery": 68, + "setpoint": 5.5, + "temperature": 15.6, + "temperature_difference": 0.0, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11" + }, + "f1fee6043d3642a9b0a65297455f008e": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Lisa", + "model_id": "158-01", + "name": "Thermostatic Radiator Badkamer 2", + "sensors": { + "battery": 92, + "setpoint": 14.0, + "temperature": 18.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + }, + "fe799307f1624099878210aa0b9f1475": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "3.0.15", + "hardware": "AME Smile 2.0 board", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": { + "af82e4ccf9c548528166d38e560662a4": { + "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." + } + }, + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 7.81 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + } +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json new file mode 100644 index 00000000000..ccfd816ff63 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 28.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": true, + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 41.5, + "intended_boiler_temperature": 0.0, + "modulation_level": 40, + "outdoor_air_temperature": 28.0, + "return_temperature": 23.8, + "water_pressure": 1.57, + "water_temperature": 22.7 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 21.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 26.3 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json new file mode 100644 index 00000000000..5a1cdebd380 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 28.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": false, + "cooling_enabled": true, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 18.0, + "modulation_level": 0, + "outdoor_air_temperature": 28.2, + "return_temperature": 22.0, + "water_pressure": 1.57, + "water_temperature": 19.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 25.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 23.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_single/data.json b/tests/components/plugwise/fixtures/p1v4_442_single/data.json new file mode 100644 index 00000000000..6dfcd7ee033 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_single/data.json @@ -0,0 +1,43 @@ +{ + "a455b61e52394b2db5081ce025a430f3": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.4.2", + "hardware": "AME Smile 2.0 board", + "location": "a455b61e52394b2db5081ce025a430f3", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile", + "name": "Smile P1", + "notifications": {}, + "vendor": "Plugwise" + }, + "ba4de7613517478da82dd9b6abea36af": { + "available": true, + "dev_class": "smartmeter", + "location": "a455b61e52394b2db5081ce025a430f3", + "model": "KFM5KAIFA-METER", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 17643.423, + "electricity_consumed_off_peak_interval": 15, + "electricity_consumed_off_peak_point": 486, + "electricity_consumed_peak_cumulative": 13966.608, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 486, + "electricity_phase_one_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 0.0, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "net_electricity_cumulative": 31610.031, + "net_electricity_point": 486 + }, + "vendor": "SHENZHEN KAIFA TECHNOLOGY \uff08CHENGDU\uff09 CO., LTD." + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/data.json new file mode 100644 index 00000000000..943325d1415 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/data.json @@ -0,0 +1,56 @@ +{ + "03e65b16e4b247a29ae0d75a78cb492e": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "4.4.2", + "hardware": "AME Smile 2.0 board", + "location": "03e65b16e4b247a29ae0d75a78cb492e", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile", + "name": "Smile P1", + "notifications": { + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } + }, + "vendor": "Plugwise" + }, + "b82b6b3322484f2ea4e25e0bd5f3d61f": { + "available": true, + "dev_class": "smartmeter", + "location": "03e65b16e4b247a29ae0d75a78cb492e", + "model": "XMX5LGF0010453051839", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 70537.898, + "electricity_consumed_off_peak_interval": 314, + "electricity_consumed_off_peak_point": 5553, + "electricity_consumed_peak_cumulative": 161328.641, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 1763, + "electricity_phase_one_produced": 0, + "electricity_phase_three_consumed": 2080, + "electricity_phase_three_produced": 0, + "electricity_phase_two_consumed": 1703, + "electricity_phase_two_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 0.0, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "gas_consumed_cumulative": 16811.37, + "gas_consumed_interval": 0.06, + "net_electricity_cumulative": 231866.539, + "net_electricity_point": 5553, + "voltage_phase_one": 233.2, + "voltage_phase_three": 234.7, + "voltage_phase_two": 234.4 + }, + "vendor": "XEMEX NV" + } +} diff --git a/tests/components/plugwise/fixtures/smile_p1_v2/data.json b/tests/components/plugwise/fixtures/smile_p1_v2/data.json new file mode 100644 index 00000000000..768dd2c2334 --- /dev/null +++ b/tests/components/plugwise/fixtures/smile_p1_v2/data.json @@ -0,0 +1,34 @@ +{ + "938696c4bcdb4b8a9a595cb38ed43913": { + "dev_class": "smartmeter", + "location": "938696c4bcdb4b8a9a595cb38ed43913", + "model": "Ene5\\T210-DESMR5.0", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 1642.74, + "electricity_consumed_off_peak_interval": 0, + "electricity_consumed_peak_cumulative": 1155.195, + "electricity_consumed_peak_interval": 250, + "electricity_consumed_point": 458, + "electricity_produced_off_peak_cumulative": 482.598, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_peak_cumulative": 1296.136, + "electricity_produced_peak_interval": 0, + "electricity_produced_point": 0, + "gas_consumed_cumulative": 584.433, + "gas_consumed_interval": 0.016, + "net_electricity_cumulative": 1019.201, + "net_electricity_point": 458 + }, + "vendor": "Ene5\\T210-DESMR5.0" + }, + "aaaa0000aaaa0000aaaa0000aaaa00aa": { + "dev_class": "gateway", + "firmware": "2.5.9", + "location": "938696c4bcdb4b8a9a595cb38ed43913", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Smile P1", + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/stretch_v31/data.json b/tests/components/plugwise/fixtures/stretch_v31/data.json new file mode 100644 index 00000000000..250839d08a8 --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/data.json @@ -0,0 +1,136 @@ +{ + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "3.1.11", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Stretch", + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "059e4d03c7a34d278add5c7a4a781d19": { + "dev_class": "washingmachine", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Wasmachine (52AC1)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "5871317346d045bc9f6b987ef25ee638": { + "dev_class": "water_heater_vessel", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4028", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Boiler (1EB31)", + "sensors": { + "electricity_consumed": 1.19, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "aac7b735042c4832ac9ff33aae4f453b": { + "dev_class": "dishwasher", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4022", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Vaatwasser (2a1ab)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.71, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "cfe95cf3de1948c0b8955125bf754614": { + "dev_class": "dryer", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Droger (52559)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "d03738edfcc947f7b8f4573571d90d2d": { + "dev_class": "switching", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "cfe95cf3de1948c0b8955125bf754614" + ], + "model": "Switchgroup", + "name": "Schakel", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "d950b314e9d8499f968e6db8d82ef78c": { + "dev_class": "report", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "5871317346d045bc9f6b987ef25ee638", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "e1c884e7dede431dadee09506ec4f859" + ], + "model": "Switchgroup", + "name": "Stroomvreters", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "e1c884e7dede431dadee09506ec4f859": { + "dev_class": "refrigerator", + "firmware": "2011-06-27T10:47:37+02:00", + "hardware": "6539-0700-7330", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle+ type F", + "name": "Koelkast (92C4A)", + "sensors": { + "electricity_consumed": 50.5, + "electricity_consumed_interval": 0.08, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "0123456789AB" + } +} diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 806c92fe7cb..92ed327b841 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -1,643 +1,633 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'devices': dict({ - '02cf28bfec924855854c544690a609ef': dict({ - 'available': True, - 'dev_class': 'vcr_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'NVR', - 'sensors': dict({ - 'electricity_consumed': 34.0, - 'electricity_consumed_interval': 9.15, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A15', + '02cf28bfec924855854c544690a609ef': dict({ + 'available': True, + 'dev_class': 'vcr_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'NVR', + 'sensors': dict({ + 'electricity_consumed': 34.0, + 'electricity_consumed_interval': 9.15, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, }), - '08963fec7c53423ca5680aa4cb502c63': dict({ - 'active_preset': 'away', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A15', + }), + '08963fec7c53423ca5680aa4cb502c63': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'temperature': 18.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'f1fee6043d3642a9b0a65297455f008e', + '680423ff840043738f42cc7f1ff97a36', ]), - 'climate_mode': 'auto', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Badkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'secondary': list([ ]), - 'select_schedule': 'Badkamer Schema', - 'sensors': dict({ - 'temperature': 18.9, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 14.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'f1fee6043d3642a9b0a65297455f008e', - '680423ff840043738f42cc7f1ff97a36', - ]), - 'secondary': list([ - ]), - }), - 'vendor': 'Plugwise', }), - '12493538af164a409c6a1c79e38afe1c': dict({ - 'active_preset': 'away', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'vendor': 'Plugwise', + }), + '12493538af164a409c6a1c79e38afe1c': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'off', + 'sensors': dict({ + 'electricity_consumed': 0.0, + 'electricity_produced': 0.0, + 'temperature': 16.5, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'df4a4a8169904cdb9c03d61a21f42140', ]), - 'climate_mode': 'heat', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Bios', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'secondary': list([ + 'a2c3583e0a6349358998b760cea82d2a', ]), - 'select_schedule': 'off', - 'sensors': dict({ - 'electricity_consumed': 0.0, - 'electricity_produced': 0.0, - 'temperature': 16.5, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 13.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'df4a4a8169904cdb9c03d61a21f42140', - ]), - 'secondary': list([ - 'a2c3583e0a6349358998b760cea82d2a', - ]), - }), - 'vendor': 'Plugwise', }), - '21f2b542c49845e6bb416884c55778d6': dict({ - 'available': True, - 'dev_class': 'game_console_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Playstation Smart Plug', - 'sensors': dict({ - 'electricity_consumed': 84.1, - 'electricity_consumed_interval': 8.6, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': False, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A12', + 'vendor': 'Plugwise', + }), + '21f2b542c49845e6bb416884c55778d6': dict({ + 'available': True, + 'dev_class': 'game_console_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Playstation Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 84.1, + 'electricity_consumed_interval': 8.6, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, }), - '446ac08dd04d4eff8ac57489757b7314': dict({ - 'active_preset': 'no_frost', - 'climate_mode': 'heat', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Garage', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'switches': dict({ + 'lock': False, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A12', + }), + '446ac08dd04d4eff8ac57489757b7314': dict({ + 'active_preset': 'no_frost', + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'sensors': dict({ + 'temperature': 15.6, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'e7693eb9582644e5b865dba8d4447cf1', ]), - 'sensors': dict({ - 'temperature': 15.6, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 5.5, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'e7693eb9582644e5b865dba8d4447cf1', - ]), - 'secondary': list([ - ]), - }), - 'vendor': 'Plugwise', - }), - '4a810418d5394b3f82727340b91ba740': dict({ - 'available': True, - 'dev_class': 'router_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'USG Smart Plug', - 'sensors': dict({ - 'electricity_consumed': 8.5, - 'electricity_consumed_interval': 0.0, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A16', - }), - '675416a629f343c495449970e2ca37b5': dict({ - 'available': True, - 'dev_class': 'router_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Ziggo Modem', - 'sensors': dict({ - 'electricity_consumed': 12.2, - 'electricity_consumed_interval': 2.97, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A01', - }), - '680423ff840043738f42cc7f1ff97a36': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '08963fec7c53423ca5680aa4cb502c63', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Thermostatic Radiator Badkamer 1', - 'sensors': dict({ - 'battery': 51, - 'setpoint': 14.0, - 'temperature': 19.1, - 'temperature_difference': -0.4, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A17', - }), - '6a3bf693d05e48e0b460c815a4fdd09d': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '82fa13f017d240daa0d0ea1775420f24', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Thermostat Jessie', - 'sensors': dict({ - 'battery': 37, - 'setpoint': 15.0, - 'temperature': 17.2, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A03', - }), - '78d1126fc4c743db81b61c20e88342a7': dict({ - 'available': True, - 'dev_class': 'central_heating_pump_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'CV Pomp', - 'sensors': dict({ - 'electricity_consumed': 35.6, - 'electricity_consumed_interval': 7.37, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A05', - }), - '82fa13f017d240daa0d0ea1775420f24': dict({ - 'active_preset': 'asleep', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'secondary': list([ ]), - 'climate_mode': 'auto', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Jessie', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + }), + 'vendor': 'Plugwise', + }), + '4a810418d5394b3f82727340b91ba740': dict({ + 'available': True, + 'dev_class': 'router_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'USG Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 8.5, + 'electricity_consumed_interval': 0.0, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A16', + }), + '675416a629f343c495449970e2ca37b5': dict({ + 'available': True, + 'dev_class': 'router_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Ziggo Modem', + 'sensors': dict({ + 'electricity_consumed': 12.2, + 'electricity_consumed_interval': 2.97, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A01', + }), + '680423ff840043738f42cc7f1ff97a36': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Thermostatic Radiator Badkamer 1', + 'sensors': dict({ + 'battery': 51, + 'setpoint': 14.0, + 'temperature': 19.1, + 'temperature_difference': -0.4, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A17', + }), + '6a3bf693d05e48e0b460c815a4fdd09d': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Thermostat Jessie', + 'sensors': dict({ + 'battery': 37, + 'setpoint': 15.0, + 'temperature': 17.2, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A03', + }), + '78d1126fc4c743db81b61c20e88342a7': dict({ + 'available': True, + 'dev_class': 'central_heating_pump_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'CV Pomp', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_consumed_interval': 7.37, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A05', + }), + '82fa13f017d240daa0d0ea1775420f24': dict({ + 'active_preset': 'asleep', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'temperature': 17.2, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + '6a3bf693d05e48e0b460c815a4fdd09d', ]), - 'select_schedule': 'CV Jessie', - 'sensors': dict({ - 'temperature': 17.2, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 15.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - '6a3bf693d05e48e0b460c815a4fdd09d', - ]), - 'secondary': list([ - 'd3da73bde12a47d5a6b8f9dad971f2ec', - ]), - }), - 'vendor': 'Plugwise', - }), - '90986d591dcd426cae3ec3e8111ff730': dict({ - 'binary_sensors': dict({ - 'heating_state': True, - }), - 'dev_class': 'heater_central', - 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', - 'model': 'Unknown', - 'name': 'OnOff', - 'sensors': dict({ - 'intended_boiler_temperature': 70.0, - 'modulation_level': 1, - 'water_temperature': 70.0, - }), - }), - 'a28f588dc4a049a483fd03a30361ad3a': dict({ - 'available': True, - 'dev_class': 'settop_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Fibaro HC2', - 'sensors': dict({ - 'electricity_consumed': 12.5, - 'electricity_consumed_interval': 3.8, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A13', - }), - 'a2c3583e0a6349358998b760cea82d2a': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '12493538af164a409c6a1c79e38afe1c', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Bios Cv Thermostatic Radiator ', - 'sensors': dict({ - 'battery': 62, - 'setpoint': 13.0, - 'temperature': 17.2, - 'temperature_difference': -0.2, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A09', - }), - 'b310b72a0e354bfab43089919b9a88bf': dict({ - 'available': True, - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Floor kraan', - 'sensors': dict({ - 'setpoint': 21.5, - 'temperature': 26.0, - 'temperature_difference': 3.5, - 'valve_position': 100, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A02', - }), - 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-08-02T02:00:00+02:00', - 'hardware': '255', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Lisa WK', - 'sensors': dict({ - 'battery': 34, - 'setpoint': 21.5, - 'temperature': 20.9, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A07', - }), - 'c50f167537524366a5af7aa3942feb1e': dict({ - 'active_preset': 'home', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'secondary': list([ + 'd3da73bde12a47d5a6b8f9dad971f2ec', ]), - 'climate_mode': 'auto', - 'control_state': 'heating', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Woonkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'GF7 Woonkamer', - 'sensors': dict({ - 'electricity_consumed': 35.6, - 'electricity_produced': 0.0, - 'temperature': 20.9, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 21.5, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'b59bcebaf94b499ea7d46e4a66fb62d8', - ]), - 'secondary': list([ - 'b310b72a0e354bfab43089919b9a88bf', - ]), - }), - 'vendor': 'Plugwise', }), - 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ - 'available': True, - 'dev_class': 'vcr_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'NAS', - 'sensors': dict({ - 'electricity_consumed': 16.5, - 'electricity_consumed_interval': 0.5, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A14', + 'vendor': 'Plugwise', + }), + '90986d591dcd426cae3ec3e8111ff730': dict({ + 'binary_sensors': dict({ + 'heating_state': True, }), - 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '82fa13f017d240daa0d0ea1775420f24', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Thermostatic Radiator Jessie', - 'sensors': dict({ - 'battery': 62, - 'setpoint': 15.0, - 'temperature': 17.1, - 'temperature_difference': 0.1, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A10', - }), - 'df4a4a8169904cdb9c03d61a21f42140': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '12493538af164a409c6a1c79e38afe1c', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Lisa Bios', - 'sensors': dict({ - 'battery': 67, - 'setpoint': 13.0, - 'temperature': 16.5, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A06', - }), - 'e7693eb9582644e5b865dba8d4447cf1': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '446ac08dd04d4eff8ac57489757b7314', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'CV Kraan Garage', - 'sensors': dict({ - 'battery': 68, - 'setpoint': 5.5, - 'temperature': 15.6, - 'temperature_difference': 0.0, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A11', - }), - 'f1fee6043d3642a9b0a65297455f008e': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '08963fec7c53423ca5680aa4cb502c63', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Thermostatic Radiator Badkamer 2', - 'sensors': dict({ - 'battery': 92, - 'setpoint': 14.0, - 'temperature': 18.9, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A08', - }), - 'fe799307f1624099878210aa0b9f1475': dict({ - 'binary_sensors': dict({ - 'plugwise_notification': True, - }), - 'dev_class': 'gateway', - 'firmware': '3.0.15', - 'hardware': 'AME Smile 2.0 board', - 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', - 'mac_address': '012345670001', - 'model': 'Gateway', - 'model_id': 'smile_open_therm', - 'name': 'Adam', - 'select_regulation_mode': 'heating', - 'sensors': dict({ - 'outdoor_temperature': 7.81, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670101', + 'dev_class': 'heater_central', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'model': 'Unknown', + 'name': 'OnOff', + 'sensors': dict({ + 'intended_boiler_temperature': 70.0, + 'modulation_level': 1, + 'water_temperature': 70.0, }), }), - 'gateway': dict({ - 'cooling_present': False, - 'gateway_id': 'fe799307f1624099878210aa0b9f1475', - 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 369, + 'a28f588dc4a049a483fd03a30361ad3a': dict({ + 'available': True, + 'dev_class': 'settop_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Fibaro HC2', + 'sensors': dict({ + 'electricity_consumed': 12.5, + 'electricity_consumed_interval': 3.8, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A13', + }), + 'a2c3583e0a6349358998b760cea82d2a': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Bios Cv Thermostatic Radiator ', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 13.0, + 'temperature': 17.2, + 'temperature_difference': -0.2, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A09', + }), + 'b310b72a0e354bfab43089919b9a88bf': dict({ + 'available': True, + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Floor kraan', + 'sensors': dict({ + 'setpoint': 21.5, + 'temperature': 26.0, + 'temperature_difference': 3.5, + 'valve_position': 100, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A02', + }), + 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-08-02T02:00:00+02:00', + 'hardware': '255', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Lisa WK', + 'sensors': dict({ + 'battery': 34, + 'setpoint': 21.5, + 'temperature': 20.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'c50f167537524366a5af7aa3942feb1e': dict({ + 'active_preset': 'home', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'heating', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Woonkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_produced': 0.0, + 'temperature': 20.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 21.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'b59bcebaf94b499ea7d46e4a66fb62d8', + ]), + 'secondary': list([ + 'b310b72a0e354bfab43089919b9a88bf', + ]), + }), + 'vendor': 'Plugwise', + }), + 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ + 'available': True, + 'dev_class': 'vcr_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'NAS', + 'sensors': dict({ + 'electricity_consumed': 16.5, + 'electricity_consumed_interval': 0.5, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A14', + }), + 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Thermostatic Radiator Jessie', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 15.0, + 'temperature': 17.1, + 'temperature_difference': 0.1, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A10', + }), + 'df4a4a8169904cdb9c03d61a21f42140': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Lisa Bios', + 'sensors': dict({ + 'battery': 67, + 'setpoint': 13.0, + 'temperature': 16.5, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A06', + }), + 'e7693eb9582644e5b865dba8d4447cf1': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '446ac08dd04d4eff8ac57489757b7314', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'CV Kraan Garage', + 'sensors': dict({ + 'battery': 68, + 'setpoint': 5.5, + 'temperature': 15.6, + 'temperature_difference': 0.0, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A11', + }), + 'f1fee6043d3642a9b0a65297455f008e': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Thermostatic Radiator Badkamer 2', + 'sensors': dict({ + 'battery': 92, + 'setpoint': 14.0, + 'temperature': 18.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A08', + }), + 'fe799307f1624099878210aa0b9f1475': dict({ + 'binary_sensors': dict({ + 'plugwise_notification': True, + }), + 'dev_class': 'gateway', + 'firmware': '3.0.15', + 'hardware': 'AME Smile 2.0 board', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'mac_address': '012345670001', + 'model': 'Gateway', + 'model_id': 'smile_open_therm', + 'name': 'Adam', 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", }), }), - 'reboot': True, - 'smile_name': 'Adam', + 'select_regulation_mode': 'heating', + 'sensors': dict({ + 'outdoor_temperature': 7.81, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670101', }), }) # --- diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 554326a72b1..7bf475086af 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -12,6 +12,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("entity_id", "expected_state"), [ @@ -35,6 +36,7 @@ async def test_anna_climate_binary_sensor_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index ab6bd3d4f29..7a481285be0 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -80,6 +80,7 @@ async def test_adam_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) async def test_adam_2_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, @@ -108,6 +109,7 @@ async def test_adam_2_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, @@ -125,18 +127,10 @@ async def test_adam_3_climate_entity_attributes( HVACMode.COOL, ] data = mock_smile_adam_heat_cool.async_update.return_value - data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( - "heating" - ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( - HVACAction.HEATING - ) - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "cooling_state" - ] = False - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "heating_state" - ] = True + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -153,18 +147,10 @@ async def test_adam_3_climate_entity_attributes( ] data = mock_smile_adam_heat_cool.async_update.return_value - data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( - "cooling" - ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( - HVACAction.COOLING - ) - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "cooling_state" - ] = True - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "heating_state" - ] = False + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -323,6 +309,7 @@ async def test_adam_climate_off_mode_change( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -349,6 +336,7 @@ async def test_anna_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_2_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -369,6 +357,7 @@ async def test_anna_2_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_3_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -386,6 +375,7 @@ async def test_anna_3_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -441,7 +431,7 @@ async def test_anna_climate_entity_climate_changes( ) data = mock_smile_anna.async_update.return_value - data.devices["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") + data["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 874c4b61a47..5f1f065fa90 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -62,6 +62,7 @@ TOM = { @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -82,6 +83,7 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("side_effect", "entry_state"), [ @@ -138,6 +140,7 @@ async def test_device_in_dr( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -232,6 +235,7 @@ async def test_migrate_unique_id_relay( @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_update_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -265,8 +269,8 @@ async def test_update_device( ) # Add a 2nd Tom/Floor - data.devices.update(TOM) - data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + data.update(TOM) + data["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( { "secondary": [ "01234567890abcdefghijklmnopqrstu", @@ -301,10 +305,10 @@ async def test_update_device( assert "01234567890abcdefghijklmnopqrstu" in item_list # Remove the existing Tom/Floor - data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + data["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( {"secondary": ["01234567890abcdefghijklmnopqrstu"]} ) - data.devices.pop("1772a4ea304041adb83f357b751341ff") + data.pop("1772a4ea304041adb83f357b751341ff") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index c5361433388..4ae461d96c8 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -17,6 +17,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_number_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -27,6 +28,7 @@ async def test_anna_number_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_max_boiler_temp_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -48,6 +50,7 @@ async def test_anna_max_boiler_temp_change( @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) async def test_adam_dhw_setpoint_change( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f06d07767f3..f6c4205b756 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -51,6 +51,7 @@ async def test_adam_change_select_entity( @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_regulation_mode( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, @@ -95,6 +96,7 @@ async def test_legacy_anna_select_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_unavailable_regulation_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 11aa68bded7..c6c6c6cc284 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -95,6 +95,7 @@ async def test_unique_id_migration_humidity( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: From 91dbe3092fda376ef5aa2052628acf42907d6a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 8 Feb 2025 16:29:00 +0100 Subject: [PATCH 359/410] Only allow single Home Connect config entry (#137088) * Make Home Connect config entry unique * Use unique ID for Home connect config entry * Remove unnecessary code * Revert "Use unique ID for Home connect config entry" This reverts commit 424131746990dfbbba05b578f61fd49f4f7cb8d7. * Added tests --- .../components/home_connect/manifest.json | 3 ++- homeassistant/generated/integrations.json | 3 ++- .../components/home_connect/test_config_flow.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 41d359446fa..94085af2fc3 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.12.3"] + "requirements": ["aiohomeconnect==0.12.3"], + "single_config_entry": true } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 026eab30f8f..6c688e07f5c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2609,7 +2609,8 @@ "name": "Home Connect", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "single_config_entry": true }, "home_plus_control": { "name": "Legrand Home+ Control", diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index c015a881343..343d648e543 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -77,3 +78,18 @@ async def test_full_flow( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_prevent_multiple_config_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we only allow one config entry.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "home_connect", context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" From 8976e7f4ea044a74140bf98a01a35b0a4e2609ac Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:33:46 +0100 Subject: [PATCH 360/410] Explicitly pass in the config_entry in zamg coordinator (#137858) explicitly pass in the config_entry in coordinator --- homeassistant/components/zamg/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py index d53c743f500..a88c97ad267 100644 --- a/homeassistant/components/zamg/coordinator.py +++ b/homeassistant/components/zamg/coordinator.py @@ -32,6 +32,7 @@ class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=MIN_TIME_BETWEEN_UPDATES, ) From 367dafdfe70724e8d81e505fff909701f6ee283a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:45:23 +0100 Subject: [PATCH 361/410] Explicitly pass in the config_entry in zeversolar coordinator (#137857) explicitly pass in the config_entry in coordinator --- homeassistant/components/zeversolar/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/zeversolar/coordinator.py b/homeassistant/components/zeversolar/coordinator.py index 9f6ff49eaf8..ec68cf4b56f 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -20,11 +20,14 @@ _LOGGER = logging.getLogger(__name__) class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): """Data update coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(minutes=1), ) From d07c2b8226c111f6cdffe6b4f5ed36ef29bc40d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:45:36 +0100 Subject: [PATCH 362/410] Explicitly pass in the config_entry in youtube coordinator (#137859) explicitly pass in the config_entry in coordinator --- homeassistant/components/youtube/__init__.py | 2 +- homeassistant/components/youtube/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index aee4b83508c..ec8a3f325cb 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - coordinator = YouTubeDataUpdateCoordinator(hass, auth) + coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 0da480f1169..476e5bb4022 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -35,12 +35,15 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, auth: AsyncConfigEntryAuth + ) -> None: """Initialize the YouTube data coordinator.""" self._auth = auth super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=15), ) From 5ade026b87edecdae41e007507adb267d1e3ca35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:45:57 +0100 Subject: [PATCH 363/410] Explicitly pass in the config_entry in ws66i coordinator (#137865) explicitly pass in the config_entry in coordinator --- homeassistant/components/ws66i/__init__.py | 1 + homeassistant/components/ws66i/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 83ad7bbf070..32c6a11f25c 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -78,6 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create the coordinator for the WS66i coordinator: Ws66iDataUpdateCoordinator = Ws66iDataUpdateCoordinator( hass, + entry, ws66i, zones, ) diff --git a/homeassistant/components/ws66i/coordinator.py b/homeassistant/components/ws66i/coordinator.py index 013e4d02b15..1b2b43963fc 100644 --- a/homeassistant/components/ws66i/coordinator.py +++ b/homeassistant/components/ws66i/coordinator.py @@ -6,6 +6,7 @@ import logging from pyws66i import WS66i, ZoneStatus +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,9 +18,12 @@ _LOGGER = logging.getLogger(__name__) class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): """DataUpdateCoordinator to gather data for WS66i Zones.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, my_api: WS66i, zones: list[int], ) -> None: @@ -27,6 +31,7 @@ class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="WS66i", update_interval=POLL_INTERVAL, ) From 3cce2d679c2226bf8a5ba0355593c7d4a0f51bd4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 17:21:09 +0100 Subject: [PATCH 364/410] Explicitly pass in the config_entry in waqi coordinator (#137873) explicitly pass in the config_entry in coordinator --- homeassistant/components/waqi/__init__.py | 2 +- homeassistant/components/waqi/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index e9feca75ee7..9821b5435d9 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = WAQIClient(session=async_get_clientsession(hass)) client.authenticate(entry.data[CONF_API_KEY]) - waqi_coordinator = WAQIDataUpdateCoordinator(hass, client) + waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index d1a44e9f5b8..86f553a86cd 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -18,11 +18,14 @@ class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=5), ) From 219b5324e954c8beb0bbc63056bea893f8a5554a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 17:21:17 +0100 Subject: [PATCH 365/410] Explicitly pass in the config_entry in watergate coordinator (#137872) explicitly pass in the config_entry in coordinator --- homeassistant/components/watergate/__init__.py | 7 ++----- homeassistant/components/watergate/coordinator.py | 14 +++++++++++++- homeassistant/components/watergate/sensor.py | 7 +++++-- homeassistant/components/watergate/valve.py | 3 +-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index fa761110339..c1747af1f11 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -16,12 +16,11 @@ from homeassistant.components.webhook import ( async_generate_url, async_register, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import WatergateDataCoordinator +from .coordinator import WatergateConfigEntry, WatergateDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,6 @@ PLATFORMS: list[Platform] = [ Platform.VALVE, ] -type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Set up Watergate from a config entry.""" @@ -52,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}" ) - coordinator = WatergateDataCoordinator(hass, watergate_client) + coordinator = WatergateDataCoordinator(hass, entry, watergate_client) entry.runtime_data = coordinator async_register( diff --git a/homeassistant/components/watergate/coordinator.py b/homeassistant/components/watergate/coordinator.py index 1d83b7a3ccb..e3f198c144d 100644 --- a/homeassistant/components/watergate/coordinator.py +++ b/homeassistant/components/watergate/coordinator.py @@ -7,6 +7,7 @@ import logging from watergate_local_api import WatergateApiException, WatergateLocalApiClient from watergate_local_api.models import DeviceState, NetworkingData, TelemetryData +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,14 +25,25 @@ class WatergateAgregatedRequests: networking: NetworkingData +type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] + + class WatergateDataCoordinator(DataUpdateCoordinator[WatergateAgregatedRequests]): """Class to manage fetching watergate data.""" - def __init__(self, hass: HomeAssistant, api: WatergateLocalApiClient) -> None: + config_entry: WatergateConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + api: WatergateLocalApiClient, + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=2), ) diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 6782a93541b..44630d2f587 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -26,8 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import WatergateConfigEntry -from .coordinator import WatergateAgregatedRequests, WatergateDataCoordinator +from .coordinator import ( + WatergateAgregatedRequests, + WatergateConfigEntry, + WatergateDataCoordinator, +) from .entity import WatergateEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py index 556b53e1d3c..ce914ebbb55 100644 --- a/homeassistant/components/watergate/valve.py +++ b/homeassistant/components/watergate/valve.py @@ -10,8 +10,7 @@ from homeassistant.components.valve import ( from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WatergateConfigEntry -from .coordinator import WatergateDataCoordinator +from .coordinator import WatergateConfigEntry, WatergateDataCoordinator from .entity import WatergateEntity ENTITY_NAME = "valve" From bd32a6ab83a87476c946351138a6307e085c8268 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 8 Feb 2025 17:54:48 +0100 Subject: [PATCH 366/410] Prolong ondilo ico update interval (#137888) --- homeassistant/components/ondilo_ico/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index bc092ad0b9a..ff1502a89fd 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -34,7 +34,7 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): hass, logger=_LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=20), + update_interval=timedelta(hours=1), ) self.api = api From 907826e909823a0eb6970d3c3d166784a0c8d580 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Feb 2025 11:56:23 -0500 Subject: [PATCH 367/410] Fix heos migration (#137887) * Fix heos migration * Fix for loop --- homeassistant/components/heos/__init__.py | 36 +++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 0c268b612ea..7bbd3765602 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -37,24 +37,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool for device in device_registry.devices.get_devices_for_config_entry_id( entry.entry_id ): - for domain, player_id in device.identifiers: - if domain == DOMAIN and not isinstance(player_id, str): - # Create set of identifiers excluding this integration - identifiers = { # type: ignore[unreachable] - (domain, identifier) - for domain, identifier in device.identifiers - if domain != DOMAIN - } - migrated_identifiers = {(DOMAIN, str(player_id))} - # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded - if not device_registry.async_get_device(migrated_identifiers): - identifiers.update(migrated_identifiers) - if len(identifiers) > 0: - device_registry.async_update_device( - device.id, new_identifiers=identifiers - ) - else: - device_registry.async_remove_device(device.id) + for ident in device.identifiers: + if ident[0] != DOMAIN or isinstance(ident[1], str): + continue + + player_id = int(ident[1]) # type: ignore[unreachable] + + # Create set of identifiers excluding this integration + identifiers = {ident for ident in device.identifiers if ident[0] != DOMAIN} + migrated_identifiers = {(DOMAIN, str(player_id))} + # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded + if not device_registry.async_get_device(migrated_identifiers): + identifiers.update(migrated_identifiers) + if len(identifiers) > 0: + device_registry.async_update_device( + device.id, new_identifiers=identifiers + ) + else: + device_registry.async_remove_device(device.id) break coordinator = HeosCoordinator(hass, entry) From 8fcc1f7e10f55aeb951d79d72e6bbf235dfd8025 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 18:51:39 +0100 Subject: [PATCH 368/410] Explicitly pass in the config_entry in v2c coordinator (#137882) explicitly pass in the config_entry in coordinator --- homeassistant/components/v2c/__init__.py | 13 ++++--------- homeassistant/components/v2c/binary_sensor.py | 3 +-- homeassistant/components/v2c/coordinator.py | 12 +++++++++--- homeassistant/components/v2c/diagnostics.py | 2 +- homeassistant/components/v2c/number.py | 3 +-- homeassistant/components/v2c/sensor.py | 3 +-- homeassistant/components/v2c/switch.py | 3 +-- 7 files changed, 18 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 0c07891df72..7cd5e71f3ae 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from pytrydan import Trydan -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -19,15 +18,11 @@ PLATFORMS: list[Platform] = [ ] -type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Set up V2C from a config entry.""" - host = entry.data[CONF_HOST] - trydan = Trydan(host, get_async_client(hass, verify_ssl=False)) - coordinator = V2CUpdateCoordinator(hass, trydan, host) + trydan = Trydan(entry.data[CONF_HOST], get_async_client(hass, verify_ssl=False)) + coordinator = V2CUpdateCoordinator(hass, entry, trydan) await coordinator.async_config_entry_first_refresh() @@ -41,6 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 28ad3665996..18724a4eada 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py index b121c84563c..de8015985f9 100644 --- a/homeassistant/components/v2c/coordinator.py +++ b/homeassistant/components/v2c/coordinator.py @@ -9,6 +9,7 @@ from pytrydan import Trydan, TrydanData from pytrydan.exceptions import TrydanError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,19 +17,24 @@ SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] + class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): """DataUpdateCoordinator to gather data from any v2c.""" - config_entry: ConfigEntry + config_entry: V2CConfigEntry - def __init__(self, hass: HomeAssistant, evse: Trydan, host: str) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: V2CConfigEntry, evse: Trydan + ) -> None: """Initialize DataUpdateCoordinator for a v2c evse.""" self.evse = evse super().__init__( hass, _LOGGER, - name=f"EVSE {host}", + config_entry=config_entry, + name=f"EVSE {config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/v2c/diagnostics.py b/homeassistant/components/v2c/diagnostics.py index 289d585b164..994f702a7bd 100644 --- a/homeassistant/components/v2c/diagnostics.py +++ b/homeassistant/components/v2c/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 V2CConfigEntry +from .coordinator import V2CConfigEntry TO_REDACT = {CONF_HOST, "title"} diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 1540b098cf1..0d6401d194f 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -17,8 +17,7 @@ from homeassistant.const import EntityCategory, UnitOfElectricCurrent from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity MIN_INTENSITY = 6 diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 97853740e9d..5b02928385b 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -26,8 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index cca7da70e48..d6ba6a3b13e 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -20,8 +20,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) From ed8ee34a2803574754653506bd0325c53018b65c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 19:24:14 +0100 Subject: [PATCH 369/410] Fix sentence-casing and description of homekit.reload action (#137894) --- homeassistant/components/homekit/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 92b836d5ec6..dcdf6892dc2 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -2,13 +2,13 @@ "options": { "step": { "yaml": { - "title": "Adjust HomeKit Options", + "title": "Adjust HomeKit options", "description": "This entry is controlled via YAML" }, "init": { "data": { - "mode": "HomeKit Mode", - "include_exclude_mode": "Inclusion Mode", + "mode": "HomeKit mode", + "include_exclude_mode": "Inclusion mode", "domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", @@ -40,14 +40,14 @@ "camera_audio": "Cameras that support audio" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Camera Configuration" + "title": "Camera configuration" }, "advanced": { "data": { "devices": "Devices (Triggers)" }, "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", - "title": "Advanced Configuration" + "title": "Advanced configuration" } } }, @@ -72,7 +72,7 @@ "services": { "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads homekit and re-process YAML-configuration." + "description": "Reloads HomeKit and re-processes the YAML-configuration." }, "reset_accessory": { "name": "Reset accessory", From 32d13f3356df921fcd68629684496c230c4b13eb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:24:38 +0100 Subject: [PATCH 370/410] Explicitly pass in the config_entry in steamist coordinator (#137930) explicitly pass in the config_entry in coordinator --- homeassistant/components/steamist/__init__.py | 5 ++--- homeassistant/components/steamist/coordinator.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 8d8401ec6fd..380f25ea8da 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -8,7 +8,7 @@ from typing import Any from aiosteamist import Steamist from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -52,9 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] coordinator = SteamistDataUpdateCoordinator( hass, + entry, Steamist(host, async_get_clientsession(hass)), - host, - entry.data.get(CONF_NAME), # Only found from discovery ) await coordinator.async_config_entry_first_refresh() if not async_get_discovery(hass, host): diff --git a/homeassistant/components/steamist/coordinator.py b/homeassistant/components/steamist/coordinator.py index c5aa7be7ddc..3f864364be7 100644 --- a/homeassistant/components/steamist/coordinator.py +++ b/homeassistant/components/steamist/coordinator.py @@ -7,6 +7,8 @@ import logging from aiosteamist import Steamist, SteamistStatus +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,20 +18,22 @@ _LOGGER = logging.getLogger(__name__) class SteamistDataUpdateCoordinator(DataUpdateCoordinator[SteamistStatus]): """DataUpdateCoordinator to gather data from a steamist steam shower.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: Steamist, - host: str, - device_name: str | None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific steamist.""" self.client = client - self.device_name = device_name + self.device_name = config_entry.data.get(CONF_NAME) # Only found from discovery super().__init__( hass, _LOGGER, - name=f"Steamist {host}", + config_entry=config_entry, + name=f"Steamist {config_entry.data[CONF_HOST]}", update_interval=timedelta(seconds=5), always_update=False, ) From e698e436d63254930342edac12fc7485b284c524 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:25:14 +0100 Subject: [PATCH 371/410] Explicitly pass in the config_entry in uptimerobot coordinator (#137883) explicitly pass in the config_entry in coordinator --- homeassistant/components/uptimerobot/__init__.py | 5 +---- homeassistant/components/uptimerobot/coordinator.py | 11 +++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index afff0c8fe03..b8619b1fe39 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS @@ -24,12 +23,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Wrong API key type detected, use the 'main' API key" ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) - dev_reg = dr.async_get(hass) hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( hass, - config_entry_id=entry.entry_id, - dev_reg=dev_reg, + entry, api=uptime_robot_api, ) diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 3069884eb99..fbadc237965 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -26,19 +26,18 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon def __init__( self, hass: HomeAssistant, - config_entry_id: str, - dev_reg: dr.DeviceRegistry, + config_entry: ConfigEntry, api: UptimeRobot, ) -> None: """Initialize coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=COORDINATOR_UPDATE_INTERVAL, ) - self._config_entry_id = config_entry_id - self._device_registry = dev_reg + self._device_registry = dr.async_get(hass) self.api = api async def _async_update_data(self) -> list[UptimeRobotMonitor]: @@ -58,7 +57,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon current_monitors = { list(device.identifiers)[0][1] for device in dr.async_entries_for_config_entry( - self._device_registry, self._config_entry_id + self._device_registry, self.config_entry.entry_id ) } new_monitors = {str(monitor.id) for monitor in monitors} @@ -73,7 +72,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon # create new devices and entities. if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry_id) + self.hass.config_entries.async_reload(self.config_entry.entry_id) ) return monitors From cfb062a5a48e9e0ff16ede3204201740851c48a1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:33:50 +0100 Subject: [PATCH 372/410] Explicitly pass in the config_entry in skybell coordinator (#137947) explicitly pass in the config_entry in coordinator --- homeassistant/components/skybell/__init__.py | 2 +- homeassistant/components/skybell/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 0282ad40254..5baa4ad83ad 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Unable to connect to Skybell service: {ex}") from ex device_coordinators: list[SkybellDataUpdateCoordinator] = [ - SkybellDataUpdateCoordinator(hass, device) for device in devices + SkybellDataUpdateCoordinator(hass, entry, device) for device in devices ] await asyncio.gather( *[ diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py index 55e34df5c63..48e67c63ac9 100644 --- a/homeassistant/components/skybell/coordinator.py +++ b/homeassistant/components/skybell/coordinator.py @@ -16,11 +16,14 @@ class SkybellDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device: SkybellDevice) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: SkybellDevice + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=device.name, update_interval=timedelta(seconds=30), ) From 074500dc8a0cfdbd816671227e66045f144e5590 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:35:41 +0100 Subject: [PATCH 373/410] Bump bring-api to version 1.0.2 (#137925) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 16767b7b0d6..b846cb1c5ca 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.1"] + "requirements": ["bring-api==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d07a3aa061..84a31529aed 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.1 +bring-api==1.0.2 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c18706cbef0..fe7fdc68099 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.1 +bring-api==1.0.2 # homeassistant.components.broadlink broadlink==0.19.0 From 2db6860b6024e3c1849f2a9d923b5cf50c3799dc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 20:36:37 +0100 Subject: [PATCH 374/410] Fix three action descriptions in xiaomi_miio (#137918) * Update strings.json * Change "a robot" to "the robot" Co-authored-by: Sebastian Muszynski --------- Co-authored-by: Sebastian Muszynski --- homeassistant/components/xiaomi_miio/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bafc1ec543b..dd49ba502f0 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -461,7 +461,7 @@ }, "switch_set_wifi_led_on": { "name": "Switch set Wi-Fi LED on", - "description": "Turns the wifi led on.", + "description": "Turns the Wi-Fi LED on.", "fields": { "entity_id": { "name": "Entity ID", @@ -471,7 +471,7 @@ }, "switch_set_wifi_led_off": { "name": "Switch set Wi-Fi LED off", - "description": "Turn the Wi-Fi led off.", + "description": "Turns the Wi-Fi LED off.", "fields": { "entity_id": { "name": "Entity ID", @@ -567,7 +567,7 @@ }, "vacuum_goto": { "name": "Vacuum go to", - "description": "Go to the specified coordinates.", + "description": "Sends the robot to the specified coordinates.", "fields": { "x_coord": { "name": "X coordinate", From 743873b2e9943f5bc8f151a00714621279fd3b51 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 20:38:28 +0100 Subject: [PATCH 375/410] Fix spelling of "Wi-Fi" in keenetic_ndms2 integration (#137920) --- homeassistant/components/keenetic_ndms2/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 765a3fc4d47..739846de0a8 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -21,7 +21,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "no_udn": "SSDP discovery info has no UDN", - "not_keenetic_ndms2": "Discovered item is not a Keenetic router" + "not_keenetic_ndms2": "Discovered device is not a Keenetic router" } }, "options": { @@ -33,7 +33,7 @@ "interfaces": "Choose interfaces to scan", "try_hotspot": "Use 'ip hotspot' data (most accurate)", "include_arp": "Use ARP data (ignored if hotspot data used)", - "include_associated": "Use WiFi AP associations data (ignored if hotspot data used)" + "include_associated": "Use Wi-Fi AP associations data (ignored if hotspot data used)" } } } From 22d6913bc59dc8a9d1c036576caf7e4f659a4978 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:40:51 +0100 Subject: [PATCH 376/410] Explicitly pass in the config_entry in simplefin coordinator (#137948) explicitly pass in the config_entry in coordinator --- homeassistant/components/simplefin/__init__.py | 10 +++------- homeassistant/components/simplefin/binary_sensor.py | 2 +- homeassistant/components/simplefin/coordinator.py | 9 +++++++-- homeassistant/components/simplefin/sensor.py | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/simplefin/__init__.py b/homeassistant/components/simplefin/__init__.py index c47b3118415..1fe2f2a6189 100644 --- a/homeassistant/components/simplefin/__init__.py +++ b/homeassistant/components/simplefin/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from simplefin4py import SimpleFin -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_ACCESS_URL -from .coordinator import SimpleFinDataUpdateCoordinator +from .coordinator import SimpleFinConfigEntry, SimpleFinDataUpdateCoordinator PLATFORMS: list[str] = [ Platform.BINARY_SENSOR, @@ -17,20 +16,17 @@ PLATFORMS: list[str] = [ ] -type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool: """Set up from a config entry.""" access_url = entry.data[CONF_ACCESS_URL] sf_client = SimpleFin(access_url) - sf_coordinator = SimpleFinDataUpdateCoordinator(hass, sf_client) + sf_coordinator = SimpleFinDataUpdateCoordinator(hass, entry, sf_client) await sf_coordinator.async_config_entry_first_refresh() entry.runtime_data = sf_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: SimpleFinConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/simplefin/binary_sensor.py b/homeassistant/components/simplefin/binary_sensor.py index 5805fc370b6..66d920fb309 100644 --- a/homeassistant/components/simplefin/binary_sensor.py +++ b/homeassistant/components/simplefin/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpleFinConfigEntry +from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity diff --git a/homeassistant/components/simplefin/coordinator.py b/homeassistant/components/simplefin/coordinator.py index 7fa5aedb7a1..08e9732c6b7 100644 --- a/homeassistant/components/simplefin/coordinator.py +++ b/homeassistant/components/simplefin/coordinator.py @@ -15,17 +15,22 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER +type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] + class SimpleFinDataUpdateCoordinator(DataUpdateCoordinator[FinancialData]): """Data update coordinator for the SimpleFIN integration.""" - config_entry: ConfigEntry + config_entry: SimpleFinConfigEntry - def __init__(self, hass: HomeAssistant, client: SimpleFin) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SimpleFinConfigEntry, client: SimpleFin + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name="simplefin", update_interval=timedelta(hours=4), ) diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py index b2167a2c014..51a96bae2be 100644 --- a/homeassistant/components/simplefin/sensor.py +++ b/homeassistant/components/simplefin/sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import SimpleFinConfigEntry +from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity From 1374fb23db34777400333f192f92c13c299a0ba3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:42:44 +0100 Subject: [PATCH 377/410] Explicitly pass in the config_entry in sleepiq coordinator (#137946) explicitly pass in the config_entry in coordinator --- homeassistant/components/sleepiq/__init__.py | 4 ++-- homeassistant/components/sleepiq/coordinator.py | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 4f54b4cd305..565611fe169 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -94,8 +94,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _async_migrate_unique_ids(hass, entry, gateway) - coordinator = SleepIQDataUpdateCoordinator(hass, gateway, email) - pause_coordinator = SleepIQPauseUpdateCoordinator(hass, gateway, email) + coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway) + pause_coordinator = SleepIQPauseUpdateCoordinator(hass, entry, gateway) # Call the SleepIQ API to refresh data await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 7fe4f964b36..46b754976e5 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -7,6 +7,8 @@ import logging from asyncsleepiq import AsyncSleepIQ +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,17 +21,20 @@ LONGER_UPDATE_INTERVAL = timedelta(minutes=5) class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: AsyncSleepIQ, - username: str, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - name=f"{username}@SleepIQ", + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQ", update_interval=UPDATE_INTERVAL, ) self.client = client @@ -45,17 +50,20 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: AsyncSleepIQ, - username: str, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - name=f"{username}@SleepIQPause", + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQPause", update_interval=LONGER_UPDATE_INTERVAL, ) self.client = client From 848ee762a732039958f69955ad9c2208b33860fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Sat, 8 Feb 2025 20:54:13 +0100 Subject: [PATCH 378/410] Add support for fireplace mode control for flexit_bacnet integration (#137594) --- .../components/flexit_bacnet/icons.json | 9 +++ .../components/flexit_bacnet/number.py | 17 +++++- .../components/flexit_bacnet/strings.json | 6 ++ .../components/flexit_bacnet/switch.py | 7 +++ tests/components/flexit_bacnet/conftest.py | 1 + .../flexit_bacnet/snapshots/test_number.ambr | 57 +++++++++++++++++++ .../flexit_bacnet/snapshots/test_switch.ambr | 47 +++++++++++++++ 7 files changed, 143 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/icons.json b/homeassistant/components/flexit_bacnet/icons.json index 7ce8b116a27..a0c5ccd5a6e 100644 --- a/homeassistant/components/flexit_bacnet/icons.json +++ b/homeassistant/components/flexit_bacnet/icons.json @@ -30,6 +30,9 @@ }, "home_supply_fan_setpoint": { "default": "mdi:fan-plus" + }, + "fireplace_mode_runtime": { + "default": "mdi:fireplace" } }, "switch": { @@ -38,6 +41,12 @@ "state": { "off": "mdi:radiator-off" } + }, + "fireplace_mode": { + "default": "mdi:fireplace", + "state": { + "off": "mdi:fireplace-off" + } } } } diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index 6e405e8e8ac..30df5370868 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -26,6 +26,9 @@ from .entity import FlexitEntity _MAX_FAN_SETPOINT = 100 _MIN_FAN_SETPOINT = 30 +_MAX_RUNTIME_DURATION = 360 +_MIN_RUNTIME_DURATION = 1 + @dataclass(kw_only=True, frozen=True) class FlexitNumberEntityDescription(NumberEntityDescription): @@ -176,6 +179,18 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_away), ), + FlexitNumberEntityDescription( + key="fireplace_mode_runtime", + translation_key="fireplace_mode_runtime", + device_class=NumberDeviceClass.DURATION, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fireplace_mode_runtime, + set_native_value_fn=lambda device: device.set_fireplace_mode_runtime, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_max_value_fn=lambda _: _MAX_RUNTIME_DURATION, + native_min_value_fn=lambda _: _MIN_RUNTIME_DURATION, + ), ) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index 7f763674d00..8888b02a3ef 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -52,6 +52,9 @@ }, "home_supply_fan_setpoint": { "name": "Home supply fan setpoint" + }, + "fireplace_mode_runtime": { + "name": "Fireplace mode runtime" } }, "sensor": { @@ -104,6 +107,9 @@ "switch": { "electric_heater": { "name": "Electric heater" + }, + "fireplace_mode": { + "name": "Fireplace mode" } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index c58e35cda75..7f12a7524b6 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -40,6 +40,13 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( turn_on_fn=lambda data: data.enable_electric_heater(), turn_off_fn=lambda data: data.disable_electric_heater(), ), + FlexitSwitchEntityDescription( + key="fireplace_mode", + translation_key="fireplace_mode", + is_on_fn=lambda data: data.fireplace_ventilation_status, + turn_on_fn=lambda data: data.trigger_fireplace_mode(), + turn_off_fn=lambda data: data.trigger_fireplace_mode(), + ), ) diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index 6ce17261bfc..09957fe496f 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -67,6 +67,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]: flexit_bacnet.air_filter_polluted = False flexit_bacnet.air_filter_exchange_interval = 8784 flexit_bacnet.electric_heater = True + flexit_bacnet.fireplace_mode_runtime = 10 # Mock fan setpoints flexit_bacnet.fan_setpoint_extract_air_fire = 56 diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index 78eefd08345..e2875c140cc 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -284,6 +284,63 @@ 'state': '56', }) # --- +# name: test_numbers[number.device_name_fireplace_mode_runtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 360, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.device_name_fireplace_mode_runtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace mode runtime', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_mode_runtime', + 'unique_id': '0000-0001-fireplace_mode_runtime', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.device_name_fireplace_mode_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Fireplace mode runtime', + 'max': 360, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.device_name_fireplace_mode_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- # name: test_numbers[number.device_name_fireplace_supply_fan_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr index d054608f1f7..1df1c12e791 100644 --- a/tests/components/flexit_bacnet/snapshots/test_switch.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_switches[switch.device_name_fireplace_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_fireplace_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace mode', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_mode', + 'unique_id': '0000-0001-fireplace_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_fireplace_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Fireplace mode', + }), + 'context': , + 'entity_id': 'switch.device_name_fireplace_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches_implementation[switch.device_name_electric_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ From 6eea232a2382b0f0cdac5a31013df916061b5448 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sat, 8 Feb 2025 20:57:56 +0100 Subject: [PATCH 379/410] Bump nhc to 0.4.10 (#137903) --- homeassistant/components/niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index b50410cd7de..83fca0ca2d6 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.4.4"] + "requirements": ["nhc==0.4.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84a31529aed..c2934a232c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1489,7 +1489,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.4 +nhc==0.4.10 # homeassistant.components.nibe_heatpump nibe==2.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe7fdc68099..83b20bbfb94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1252,7 +1252,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.4 +nhc==0.4.10 # homeassistant.components.nibe_heatpump nibe==2.14.0 From 5871ece4df5400f1e48ad5ff6533c0e24a7a4c56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:11:30 +0100 Subject: [PATCH 380/410] Explicitly pass in the config_entry in shelly coordinator (#137951) explicitly pass in the config_entry in coordinator --- .../components/shelly/bluetooth/__init__.py | 2 +- .../components/shelly/coordinator.py | 75 ++++++++++--------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 366d5c51d25..d7eb020d671 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -25,7 +25,7 @@ async def async_connect_scanner( ) -> CALLBACK_TYPE: """Connect scanner.""" device = coordinator.device - entry = coordinator.entry + entry = coordinator.config_entry source = format_mac(coordinator.mac).upper() scanner = create_scanner(source, entry.title) unload_callbacks = [ diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f2a01240f70..ad35ec32299 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -95,6 +95,8 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( ): """Coordinator for a Shelly device.""" + config_entry: ShellyConfigEntry + def __init__( self, hass: HomeAssistant, @@ -103,7 +105,6 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( update_interval: float, ) -> None: """Initialize the Shelly device coordinator.""" - self.entry = entry self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None @@ -112,7 +113,13 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( # The device has come online at least once. In the case of a sleeping RPC # device, this means that the device has connected to the WS server at least once. self._came_online_once = False - super().__init__(hass, LOGGER, name=device_name, update_interval=interval_td) + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=device_name, + update_interval=interval_td, + ) self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( hass, @@ -130,12 +137,12 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def model(self) -> str: """Model of the device.""" - return cast(str, self.entry.data["model"]) + return cast(str, self.config_entry.data["model"]) @cached_property def mac(self) -> str: """Mac address of the device.""" - return cast(str, self.entry.unique_id) + return cast(str, self.config_entry.unique_id) @property def sw_version(self) -> str: @@ -145,14 +152,14 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" self._pending_platforms = pending_platforms dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, + config_entry_id=self.config_entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, @@ -160,8 +167,8 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( model=MODEL_NAMES.get(self.model), model_id=self.model, sw_version=self.sw_version, - hw_version=f"gen{get_device_entry_gen(self.entry)}", - configuration_url=f"http://{get_host(self.entry.data[CONF_HOST])}:{get_http_port(self.entry.data)}", + hw_version=f"gen{get_device_entry_gen(self.config_entry)}", + configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", ) self.device_id = device_entry.id @@ -179,18 +186,18 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( LOGGER.debug("Connecting to Shelly Device - %s", self.name) try: await self.device.initialize() - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) except (DeviceConnectionError, MacAddressMismatchError) as err: LOGGER.debug( "Error connecting to Shelly device %s, error: %r", self.name, err ) return False except InvalidAuthError: - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) return False if not self.device.firmware_supported: - async_create_issue_unsupported_firmware(self.hass, self.entry) + async_create_issue_unsupported_firmware(self.hass, self.config_entry) return False if not self._pending_platforms: @@ -200,7 +207,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( platforms = self._pending_platforms self._pending_platforms = None - data = {**self.entry.data} + data = {**self.config_entry.data} # Update sleep_period old_sleep_period = data[CONF_SLEEP_PERIOD] @@ -211,10 +218,12 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( if new_sleep_period != old_sleep_period: data[CONF_SLEEP_PERIOD] = new_sleep_period - self.hass.config_entries.async_update_entry(self.entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) # Resume platform setup - await self.hass.config_entries.async_forward_entry_setups(self.entry, platforms) + await self.hass.config_entries.async_forward_entry_setups( + self.config_entry, platforms + ) return True @@ -222,7 +231,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( """Reload entry.""" self._debounced_reload.async_cancel() LOGGER.debug("Reloading entry %s", self.name) - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) async def async_shutdown_device_and_start_reauth(self) -> None: """Shutdown Shelly device and start reauth flow.""" @@ -230,7 +239,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( # and won't be able to send commands to the device self.last_update_success = False await self.shutdown() - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): @@ -240,9 +249,8 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self, hass: HomeAssistant, entry: ShellyConfigEntry, device: BlockDevice ) -> None: """Initialize the Shelly block device coordinator.""" - self.entry = entry - if self.sleep_period: - update_interval = UPDATE_PERIOD_MULTIPLIER * self.sleep_period + if sleep_period := entry.data.get(CONF_SLEEP_PERIOD, 0): + update_interval = UPDATE_PERIOD_MULTIPLIER * sleep_period else: update_interval = ( UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] @@ -385,7 +393,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): LOGGER.debug("Shelly %s handle update, type: %s", self.name, update_type) if update_type is BlockUpdateType.ONLINE: self._came_online_once = True - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_device_connect_task(), "block device online", @@ -415,7 +423,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1", translation_key="push_update_failure", translation_placeholders={ - "device_name": self.entry.title, + "device_name": self.config_entry.title, "ip_address": self.device.ip_address, }, ) @@ -462,7 +470,7 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): @@ -472,9 +480,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self, hass: HomeAssistant, entry: ShellyConfigEntry, device: RpcDevice ) -> None: """Initialize the Shelly RPC device coordinator.""" - self.entry = entry - if self.sleep_period: - update_interval = UPDATE_PERIOD_MULTIPLIER * self.sleep_period + if sleep_period := entry.data.get(CONF_SLEEP_PERIOD, 0): + update_interval = UPDATE_PERIOD_MULTIPLIER * sleep_period else: update_interval = RPC_RECONNECT_INTERVAL super().__init__(hass, entry, device, update_interval) @@ -514,9 +521,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ): return False - data = {**self.entry.data} + data = {**self.config_entry.data} data[CONF_SLEEP_PERIOD] = wakeup_period - self.hass.config_entries.async_update_entry(self.entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) update_interval = UPDATE_PERIOD_MULTIPLIER * wakeup_period self.update_interval = timedelta(seconds=update_interval) @@ -693,7 +700,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_connect_ble_scanner(self) -> None: """Connect BLE scanner.""" - ble_scanner_mode = self.entry.options.get( + ble_scanner_mode = self.config_entry.options.get( CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: @@ -719,7 +726,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ): LOGGER.debug("Device %s already connected/connecting", self.name) return - self._connect_task = self.entry.async_create_background_task( + self._connect_task = self.config_entry.async_create_background_task( self.hass, self._async_device_connect_task(), "rpc device online", @@ -736,13 +743,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._came_online_once = True self._async_handle_rpc_device_online() elif update_type is RpcUpdateType.INITIALIZED: - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_connected(), "rpc device init", eager_start=True ) # Make sure entities are marked available self.async_set_updated_data(None) elif update_type is RpcUpdateType.DISCONNECTED: - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_disconnected(True), "rpc device disconnected", @@ -753,7 +760,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) if self.sleep_period: - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) @@ -763,7 +770,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.device.subscribe_updates(self._async_handle_update) if self.device.initialized: # If we are already initialized, we are connected - self.entry.async_create_task( + self.config_entry.async_create_task( self.hass, self._async_connected(), eager_start=True ) @@ -775,7 +782,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): await async_stop_scanner(self.device) await super().shutdown() except InvalidAuthError: - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) return except DeviceConnectionError as err: # If the device is restarting or has gone offline before From 022119e74f70ddc3104d96227c8b2e8db31e1ad9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:12:10 +0100 Subject: [PATCH 381/410] Explicitly pass in the config_entry in surepetcare coordinator (#137926) explicitly pass in the config_entry in coordinator --- homeassistant/components/surepetcare/__init__.py | 2 +- homeassistant/components/surepetcare/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index e1f846d63a7..130242b7742 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -38,8 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( - entry, hass, + entry, ) except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") diff --git a/homeassistant/components/surepetcare/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index a80e96ad185..d8112cebc90 100644 --- a/homeassistant/components/surepetcare/coordinator.py +++ b/homeassistant/components/surepetcare/coordinator.py @@ -33,7 +33,9 @@ SCAN_INTERVAL = timedelta(minutes=3) class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): """Handle Surepetcare data.""" - def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the data handler.""" self.surepy = Surepy( entry.data[CONF_USERNAME], @@ -51,6 +53,7 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]) super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From a7dbcf72c28a3fe1bea51703eded6f0b9cd96f47 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:14:47 +0100 Subject: [PATCH 382/410] Explicitly pass in the config_entry in swiss_public_transport coordinator (#137924) explicitly pass in the config_entry in coordinator --- homeassistant/components/swiss_public_transport/__init__.py | 4 +++- .../components/swiss_public_transport/coordinator.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 628f6e95c2a..0d0c4dc6169 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -96,7 +96,9 @@ async def async_setup_entry( }, ) from e - coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset) + coordinator = SwissPublicTransportDataUpdateCoordinator( + hass, entry, opendata, time_offset + ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index 81322117a6f..32b52122c7d 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -61,6 +61,7 @@ class SwissPublicTransportDataUpdateCoordinator( def __init__( self, hass: HomeAssistant, + config_entry: SwissPublicTransportConfigEntry, opendata: OpendataTransport, time_offset: dict[str, int] | None, ) -> None: @@ -68,6 +69,7 @@ class SwissPublicTransportDataUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME), ) From 43569df53785ac9450e09370105c973eed5e098f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:19:08 +0100 Subject: [PATCH 383/410] Explicitly pass in the config_entry in switchbee coordinator (#137923) explicitly pass in the config_entry in coordinator --- homeassistant/components/switchbee/__init__.py | 5 +---- homeassistant/components/switchbee/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index a2a3ecf0df9..6e4bf004a3d 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -63,10 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass, verify_ssl=False) api = await get_api_object(central_unit, user, password, websession) - coordinator = SwitchBeeCoordinator( - hass, - api, - ) + coordinator = SwitchBeeCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index 49400e3c28d..b0ea1707be8 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -10,6 +10,7 @@ from switchbee.api import CentralUnitPolling, CentralUnitWsRPC from switchbee.api.central_unit import SwitchBeeError from switchbee.device import DeviceType, SwitchBeeBaseDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,9 +23,12 @@ _LOGGER = logging.getLogger(__name__) class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevice]]): """Class to manage fetching SwitchBee data API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, swb_api: CentralUnitPolling | CentralUnitWsRPC, ) -> None: """Initialize.""" @@ -39,6 +43,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL_SEC[type(self.api)]), ) From bcc3e6d31c4acb56e84ba19b5e0306387a28d5bd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:19:38 +0100 Subject: [PATCH 384/410] Explicitly pass in the config_entry in streamlabswater coordinator (#137927) explicitly pass in the config_entry in coordinator --- homeassistant/components/streamlabswater/__init__.py | 2 +- homeassistant/components/streamlabswater/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 313fc1f24c5..1c1357a9b2b 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key = entry.data[CONF_API_KEY] client = StreamlabsClient(api_key) - coordinator = StreamlabsCoordinator(hass, client) + coordinator = StreamlabsCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index 56e67abe222..df4a6056b36 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from streamlabswater.streamlabswater import StreamlabsClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,15 +26,19 @@ class StreamlabsData: class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): """Coordinator for Streamlabs.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: StreamlabsClient, ) -> None: """Coordinator for Streamlabs.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Streamlabs", update_interval=timedelta(seconds=60), ) From 3ec872fbfeaa234ea18ff99b38d7db5d0fae84e0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:25:10 +0100 Subject: [PATCH 385/410] Explicitly pass in the config_entry in yardian coordinator (#137862) explicitly pass in the config_entry in coordinator --- homeassistant/components/yardian/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index b0c8a882474..da016ca4ec4 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -28,6 +28,8 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): """Coordinator for Yardian API calls.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -38,6 +40,7 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry.title, update_interval=SCAN_INTERVAL, always_update=False, From c17007e17bdb1b24069a61ea3f5caf314597d3a5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:25:48 +0100 Subject: [PATCH 386/410] Explicitly pass in the config_entry in xbox coordinator (#137864) explicitly pass in the config_entry in coordinator --- homeassistant/components/xbox/__init__.py | 2 +- homeassistant/components/xbox/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index ab0d510a709..30bc7d59417 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: consoles.dict(), ) - coordinator = XboxUpdateCoordinator(hass, client, consoles) + coordinator = XboxUpdateCoordinator(hass, entry, client, consoles) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 4012820c43c..62c7a35e88b 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -20,6 +20,7 @@ from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleStatus, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -64,9 +65,12 @@ class XboxData: class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): """Store Xbox Console Status.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: XboxLiveClient, consoles: SmartglassConsoleList, ) -> None: @@ -74,6 +78,7 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) From beb5c3b83875a391771f96646912b192653f87b8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:27:20 +0100 Subject: [PATCH 387/410] Explicitly pass in the config_entry in slide_local coordinator (#137945) explicitly pass in the config_entry in coordinator --- .../components/slide_local/__init__.py | 4 +-- .../components/slide_local/button.py | 3 +-- .../components/slide_local/config_flow.py | 2 +- .../components/slide_local/coordinator.py | 26 ++++++++++++------- homeassistant/components/slide_local/cover.py | 3 +-- .../components/slide_local/diagnostics.py | 2 +- .../components/slide_local/switch.py | 3 +-- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 5b4867bf337..4690fe8016c 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SWITCH] -type SlideConfigEntry = ConfigEntry[SlideCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index faca7cb3f2b..12474969ca6 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SlideConfigEntry from .const import DOMAIN -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 4ceb347568f..96aac1a135c 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -20,8 +20,8 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DOMAIN +from .coordinator import SlideConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py index e5311967198..cbc3e653739 100644 --- a/homeassistant/components/slide_local/coordinator.py +++ b/homeassistant/components/slide_local/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import Any from goslideapi.goslideapi import ( AuthenticationFailed, @@ -14,6 +14,7 @@ from goslideapi.goslideapi import ( GoSlideLocal as SlideLocalApi, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, @@ -31,23 +32,30 @@ from .const import DEFAULT_OFFSET, DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import SlideConfigEntry +type SlideConfigEntry = ConfigEntry[SlideCoordinator] class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get and update the latest data.""" - def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None: + config_entry: SlideConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SlideConfigEntry) -> None: """Initialize the data object.""" super().__init__( - hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15) + hass, + _LOGGER, + config_entry=config_entry, + name="Slide", + update_interval=timedelta(seconds=15), ) self.slide = SlideLocalApi() - self.api_version = entry.data[CONF_API_VERSION] - self.mac = entry.data[CONF_MAC] - self.host = entry.data[CONF_HOST] - self.password = entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + self.api_version = config_entry.data[CONF_API_VERSION] + self.mac = config_entry.data[CONF_MAC] + self.host = config_entry.data[CONF_HOST] + self.password = ( + config_entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + ) async def _async_setup(self) -> None: """Do initialization logic for Slide coordinator.""" diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py index cf04f46d139..0e5e647dea8 100644 --- a/homeassistant/components/slide_local/cover.py +++ b/homeassistant/components/slide_local/cover.py @@ -10,9 +10,8 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/slide_local/diagnostics.py b/homeassistant/components/slide_local/diagnostics.py index 2655cb5fada..6a70720a14a 100644 --- a/homeassistant/components/slide_local/diagnostics.py +++ b/homeassistant/components/slide_local/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import SlideConfigEntry +from .coordinator import SlideConfigEntry TO_REDACT = [ CONF_PASSWORD, diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index 0471dfcc4e6..8de608b7fc0 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SlideConfigEntry from .const import DOMAIN -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity PARALLEL_UPDATES = 1 From 12072b625c25f6e262306e940389780ff8f50d09 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:28:48 +0100 Subject: [PATCH 388/410] Explicitly pass in the config_entry in solaredge coordinator (#137941) explicitly pass in the config_entry in coordinator --- .../components/solaredge/coordinator.py | 35 +++++++++++++++---- homeassistant/components/solaredge/sensor.py | 20 +++++++---- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index d37cf355fce..44f015eedeb 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import date, datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from aiosolaredge import SolarEdge from stringcase import snakecase @@ -21,13 +21,22 @@ from .const import ( POWER_FLOW_UPDATE_DELAY, ) +if TYPE_CHECKING: + from .types import SolarEdgeConfigEntry + class SolarEdgeDataService(ABC): """Get and update the latest data.""" coordinator: DataUpdateCoordinator[None] - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the data object.""" self.api = api self.site_id = site_id @@ -36,6 +45,7 @@ class SolarEdgeDataService(ABC): self.attributes: dict[str, Any] = {} self.hass = hass + self.config_entry = config_entry @callback def async_setup(self) -> None: @@ -43,6 +53,7 @@ class SolarEdgeDataService(ABC): self.coordinator = DataUpdateCoordinator( self.hass, LOGGER, + config_entry=self.config_entry, name=str(self), update_method=self.async_update_data, update_interval=self.update_interval, @@ -174,9 +185,15 @@ class SolarEdgeInventoryDataService(SolarEdgeDataService): class SolarEdgeEnergyDetailsService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the power flow data service.""" - super().__init__(hass, api, site_id) + super().__init__(hass, config_entry, api, site_id) self.unit = None @@ -234,9 +251,15 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService): class SolarEdgePowerFlowDataService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the power flow data service.""" - super().__init__(hass, api, site_id) + super().__init__(hass, config_entry, api, site_id) self.unit = None diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 4b2398d15c2..004335b644b 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -206,7 +206,7 @@ async def async_setup_entry( """Add an solarEdge entry.""" # Add the needed sensors to hass api = entry.runtime_data[DATA_API_CLIENT] - sensor_factory = SolarEdgeSensorFactory(hass, entry.data[CONF_SITE_ID], api) + sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() @@ -222,14 +222,20 @@ async def async_setup_entry( class SolarEdgeSensorFactory: """Factory which creates sensors based on the sensor_key.""" - def __init__(self, hass: HomeAssistant, site_id: str, api: SolarEdge) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + site_id: str, + api: SolarEdge, + ) -> None: """Initialize the factory.""" - details = SolarEdgeDetailsDataService(hass, api, site_id) - overview = SolarEdgeOverviewDataService(hass, api, site_id) - inventory = SolarEdgeInventoryDataService(hass, api, site_id) - flow = SolarEdgePowerFlowDataService(hass, api, site_id) - energy = SolarEdgeEnergyDetailsService(hass, api, site_id) + details = SolarEdgeDetailsDataService(hass, config_entry, api, site_id) + overview = SolarEdgeOverviewDataService(hass, config_entry, api, site_id) + inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) + flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) + energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) self.all_services = (details, overview, inventory, flow, energy) From 61ce1fc009a32f065985f0bc03e89220b3cfde0c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:33:36 +0100 Subject: [PATCH 389/410] Explicitly pass in the config_entry in samsungtv coordinator (#137962) explicitly pass in the config_entry in coordinator --- homeassistant/components/samsungtv/__init__.py | 7 ++----- homeassistant/components/samsungtv/coordinator.py | 12 ++++++++++-- homeassistant/components/samsungtv/diagnostics.py | 2 +- homeassistant/components/samsungtv/helpers.py | 2 +- homeassistant/components/samsungtv/media_player.py | 3 +-- homeassistant/components/samsungtv/remote.py | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 6d4e491b839..e416cd35765 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -44,14 +44,11 @@ from .const import ( UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -from .coordinator import SamsungTVDataUpdateCoordinator +from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] -SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] - - @callback def _async_get_device_bridge( hass: HomeAssistant, data: dict[str, Any] @@ -165,7 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> entry.async_on_unload(debounced_reloader.async_shutdown) entry.async_on_unload(entry.add_update_listener(debounced_reloader.async_call)) - coordinator = SamsungTVDataUpdateCoordinator(hass, bridge) + coordinator = SamsungTVDataUpdateCoordinator(hass, entry, bridge) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 92d8dc8fa84..443e62b13fb 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -15,17 +15,25 @@ from .const import DOMAIN, LOGGER SCAN_INTERVAL = 10 +type SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] + class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): """Coordinator for the SamsungTV integration.""" - config_entry: ConfigEntry + config_entry: SamsungTVConfigEntry - def __init__(self, hass: HomeAssistant, bridge: SamsungTVBridge) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SamsungTVConfigEntry, + bridge: SamsungTVBridge, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL), ) diff --git a/homeassistant/components/samsungtv/diagnostics.py b/homeassistant/components/samsungtv/diagnostics.py index ebca8d2543b..667d23ba631 100644 --- a/homeassistant/components/samsungtv/diagnostics.py +++ b/homeassistant/components/samsungtv/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from . import SamsungTVConfigEntry from .const import CONF_SESSION_ID +from .coordinator import SamsungTVConfigEntry TO_REDACT = {CONF_TOKEN, CONF_SESSION_ID} diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py index 4e8dd00d486..b4075b8117f 100644 --- a/homeassistant/components/samsungtv/helpers.py +++ b/homeassistant/components/samsungtv/helpers.py @@ -7,9 +7,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import SamsungTVConfigEntry from .bridge import SamsungTVBridge from .const import DOMAIN +from .coordinator import SamsungTVConfigEntry @callback diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 7180e8a0c1a..9db9916c24a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -34,10 +34,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.async_ import create_eager_task -from . import SamsungTVConfigEntry from .bridge import SamsungTVWSBridge from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER -from .coordinator import SamsungTVDataUpdateCoordinator +from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator from .entity import SamsungTVEntity SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 401a5d383f0..3d2529153be 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -9,8 +9,8 @@ from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SamsungTVConfigEntry from .const import LOGGER +from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity From 93dad987f89e453629201ef40acdfd068efba8b3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:35:19 +0100 Subject: [PATCH 390/410] Explicitly pass in the config_entry in sanix coordinator (#137960) explicitly pass in the config_entry in coordinator --- homeassistant/components/sanix/__init__.py | 2 +- homeassistant/components/sanix/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py index c8c5567eedc..60cc5b56f2e 100644 --- a/homeassistant/components/sanix/__init__.py +++ b/homeassistant/components/sanix/__init__.py @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = entry.data[CONF_TOKEN] sanix_api = Sanix(serial_no, token) - coordinator = SanixCoordinator(hass, sanix_api) + coordinator = SanixCoordinator(hass, entry, sanix_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py index d6362337a38..64d28fa9191 100644 --- a/homeassistant/components/sanix/coordinator.py +++ b/homeassistant/components/sanix/coordinator.py @@ -21,10 +21,16 @@ class SanixCoordinator(DataUpdateCoordinator[Measurement]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, sanix_api: Sanix) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, sanix_api: Sanix + ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=MANUFACTURER, update_interval=timedelta(hours=1) + hass, + _LOGGER, + config_entry=config_entry, + name=MANUFACTURER, + update_interval=timedelta(hours=1), ) self._sanix_api = sanix_api From 071b46055b11eae28b9c31fdc3c650927828b76b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:36:14 +0100 Subject: [PATCH 391/410] Explicitly pass in the config_entry in steam_online coordinator (#137929) explicitly pass in the config_entry in coordinator --- homeassistant/components/steam_online/__init__.py | 6 ++---- homeassistant/components/steam_online/config_flow.py | 2 +- homeassistant/components/steam_online/coordinator.py | 8 ++++---- homeassistant/components/steam_online/sensor.py | 3 +-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 6e45758fb94..7a2c32cb4d5 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -2,19 +2,17 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import SteamDataUpdateCoordinator +from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Set up Steam from a config entry.""" - coordinator = SteamDataUpdateCoordinator(hass) + coordinator = SteamDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 69009fca8c4..57c75f0a704 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -18,8 +18,8 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from . import SteamConfigEntry from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS +from .coordinator import SteamConfigEntry # To avoid too long request URIs, the amount of ids to request is limited MAX_IDS_TO_REQUEST = 275 diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 81a3bb0d898..731154183ed 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING import steam from steam.api import _interface_method as INTMethod +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -15,8 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ACCOUNTS, DOMAIN, LOGGER -if TYPE_CHECKING: - from . import SteamConfigEntry +type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] class SteamDataUpdateCoordinator( @@ -26,11 +25,12 @@ class SteamDataUpdateCoordinator( config_entry: SteamConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SteamConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 058bb386383..625a8b95979 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp -from . import SteamConfigEntry from .const import ( CONF_ACCOUNTS, STEAM_API_URL, @@ -21,7 +20,7 @@ from .const import ( STEAM_MAIN_IMAGE_FILE, STEAM_STATUSES, ) -from .coordinator import SteamDataUpdateCoordinator +from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator from .entity import SteamEntity PARALLEL_UPDATES = 1 From 13dbeed5c21c95d5eb9cf444c6758d8d22a3c495 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:36:59 +0100 Subject: [PATCH 392/410] Explicitly pass in the config_entry in stookwijzer coordinator (#137928) explicitly pass in the config_entry in coordinator --- homeassistant/components/stookwijzer/coordinator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/coordinator.py b/homeassistant/components/stookwijzer/coordinator.py index 23092bed66e..8f81494b7d5 100644 --- a/homeassistant/components/stookwijzer/coordinator.py +++ b/homeassistant/components/stookwijzer/coordinator.py @@ -20,18 +20,23 @@ type StookwijzerConfigEntry = ConfigEntry[StookwijzerCoordinator] class StookwijzerCoordinator(DataUpdateCoordinator[None]): """Stookwijzer update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: StookwijzerConfigEntry) -> None: + config_entry: StookwijzerConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: StookwijzerConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) self.client = Stookwijzer( async_get_clientsession(hass), - entry.data[CONF_LATITUDE], - entry.data[CONF_LONGITUDE], + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], ) async def _async_update_data(self) -> None: From b512838d1e3e7849d841bef25ca1aa948ca42c85 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:39:15 +0100 Subject: [PATCH 393/410] Explicitly pass in the config_entry in smarty coordinator (#137944) explicitly pass in the config_entry in coordinator --- homeassistant/components/smarty/__init__.py | 2 +- homeassistant/components/smarty/coordinator.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0e1e99aa444..aab8c6ab3c7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -89,7 +89,7 @@ async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" - coordinator = SmartyCoordinator(hass) + coordinator = SmartyCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/smarty/coordinator.py b/homeassistant/components/smarty/coordinator.py index d7f3e2452d1..a55c9f2e78f 100644 --- a/homeassistant/components/smarty/coordinator.py +++ b/homeassistant/components/smarty/coordinator.py @@ -22,15 +22,16 @@ class SmartyCoordinator(DataUpdateCoordinator[None]): software_version: str configuration_version: str - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SmartyConfigEntry) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name="Smarty", update_interval=timedelta(seconds=30), ) - self.client = Smarty(host=self.config_entry.data[CONF_HOST]) + self.client = Smarty(host=config_entry.data[CONF_HOST]) async def _async_setup(self) -> None: if not await self.hass.async_add_executor_job(self.client.update): From a59d829e6a311443fd1799849889cdd9bd6eb0e3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:40:51 +0100 Subject: [PATCH 394/410] Explicitly pass in the config_entry in seventeentrack coordinator (#137956) explicitly pass in the config_entry in coordinator --- homeassistant/components/seventeentrack/__init__.py | 2 +- homeassistant/components/seventeentrack/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 695ca179966..235a5338cb6 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SeventeenTrackError as err: raise ConfigEntryNotReady from err - seventeen_coordinator = SeventeenTrackCoordinator(hass, client) + seventeen_coordinator = SeventeenTrackCoordinator(hass, entry, client) await seventeen_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py index 3e27f9f0369..107f1d48a21 100644 --- a/homeassistant/components/seventeentrack/coordinator.py +++ b/homeassistant/components/seventeentrack/coordinator.py @@ -34,11 +34,17 @@ class SeventeenTrackCoordinator(DataUpdateCoordinator[SeventeenTrackData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, client: SeventeenTrackClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: SeventeenTrackClient, + ) -> None: """Initialize.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) From 07e9d806078342cd001b1ed459a1e7778648f6d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:46:05 +0100 Subject: [PATCH 395/410] Explicitly pass in the config_entry in weheat coordinator (#137868) explicitly pass in the config_entry in coordinator --- homeassistant/components/weheat/__init__.py | 7 ++----- homeassistant/components/weheat/binary_sensor.py | 3 +-- homeassistant/components/weheat/coordinator.py | 7 +++++++ homeassistant/components/weheat/sensor.py | 3 +-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index d8d8616c867..b67c3540dc5 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -8,7 +8,6 @@ import aiohttp from weheat.abstractions.discovery import HeatPumpDiscovery from weheat.exceptions import UnauthorizedException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -19,12 +18,10 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .const import API_URL, LOGGER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool: """Set up Weheat from a config entry.""" @@ -58,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, session, pump_info) + new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) await new_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 1fb8f614a40..0ffa876ad0f 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WeheatConfigEntry -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index 4a85380e4a3..d7e53258e9b 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -13,6 +13,7 @@ from weheat.exceptions import ( UnauthorizedException, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -30,13 +31,18 @@ EXCEPTIONS = ( ApiException, ) +type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """A custom coordinator for the Weheat heatpump integration.""" + config_entry: WeheatConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, ) -> None: @@ -44,6 +50,7 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 2d840aec86a..5d948c6d565 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -22,13 +22,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WeheatConfigEntry from .const import ( DISPLAY_PRECISION_COP, DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates From 2ef4e75014aea82353de92e921f0f07dbd5c7b90 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:51:28 +0100 Subject: [PATCH 396/410] Explicitly pass in the config_entry in yolink coordinator (#137861) explicitly pass in the config_entry in coordinator --- homeassistant/components/yolink/__init__.py | 2 +- homeassistant/components/yolink/coordinator.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 004c5a70cc1..0c92aa696ca 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -152,7 +152,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: paried_device_id := device_pairing_mapping.get(device.device_id) ) is not None: paried_device = yolink_home.get_device(paried_device_id) - device_coordinator = YoLinkCoordinator(hass, device, paried_device) + device_coordinator = YoLinkCoordinator(hass, entry, device, paried_device) try: await device_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index b7db36541b1..d18a37bd276 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -9,6 +9,7 @@ import logging from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,9 +22,12 @@ _LOGGER = logging.getLogger(__name__) class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, device: YoLinkDevice, paired_device: YoLinkDevice | None = None, ) -> None: @@ -34,7 +38,11 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): data at first update """ super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=30), ) self.device = device self.paired_device = paired_device From c47a97a4f043e582c3e2e5ca6f01d14002fd000c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:54:12 +0100 Subject: [PATCH 397/410] Explicitly pass in the config_entry in romy coordinator (#137967) explicitly pass in the config_entry in coordinator --- homeassistant/components/romy/__init__.py | 2 +- homeassistant/components/romy/coordinator.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py index 352f5f3715a..be227645122 100644 --- a/homeassistant/components/romy/__init__.py +++ b/homeassistant/components/romy/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_HOST], config_entry.data.get(CONF_PASSWORD, "") ) - coordinator = RomyVacuumCoordinator(hass, new_romy) + coordinator = RomyVacuumCoordinator(hass, config_entry, new_romy) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index 5868eae70e2..d666ec44f80 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -2,6 +2,7 @@ from romy import RomyRobot +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -11,9 +12,19 @@ from .const import DOMAIN, LOGGER, UPDATE_INTERVAL class RomyVacuumCoordinator(DataUpdateCoordinator[None]): """ROMY Vacuum Coordinator.""" - def __init__(self, hass: HomeAssistant, romy: RomyRobot) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, romy: RomyRobot + ) -> None: """Initialize.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) self.hass = hass self.romy = romy From b338de9a308d8d3c3b8ab70ee41b4841e03672eb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:57:03 +0100 Subject: [PATCH 398/410] Explicitly pass in the config_entry in tado coordinator (#137916) explicitly pass in the config_entry in coordinator --- homeassistant/components/tado/__init__.py | 4 +++- homeassistant/components/tado/coordinator.py | 24 ++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 3e42e33489f..4087183bfe5 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -87,7 +87,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool @callback -def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): +def _async_import_options_from_data_if_missing( + hass: HomeAssistant, entry: TadoConfigEntry +): options = dict(entry.options) if CONF_FALLBACK not in options: options[CONF_FALLBACK] = entry.data.get( diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index ddec9e7f292..6e932c8ccfc 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -4,18 +4,20 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from PyTado.interface import Tado from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TadoConfigEntry + from .const import ( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT, @@ -31,8 +33,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) -type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator] - class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage API calls from and to Tado via PyTado.""" @@ -45,7 +45,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + config_entry: TadoConfigEntry, tado: Tado, debug: bool = False, ) -> None: @@ -53,13 +53,16 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) self._tado = tado - self._username = entry.data[CONF_USERNAME] - self._password = entry.data[CONF_PASSWORD] - self._fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) + self._username = config_entry.data[CONF_USERNAME] + self._password = config_entry.data[CONF_PASSWORD] + self._fallback = config_entry.options.get( + CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT + ) self._debug = debug self.home_id: int @@ -343,16 +346,19 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" + config_entry: TadoConfigEntry + def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + config_entry: TadoConfigEntry, tado: Tado, ) -> None: """Initialize the Tado data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_MOBILE_DEVICE_INTERVAL, ) From a2a55d9ff0ad0053a9dd556ced6b18a5743d56e3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:57:41 +0100 Subject: [PATCH 399/410] Explicitly pass in the config_entry in weatherkit coordinator (#137869) explicitly pass in the config_entry in coordinator --- homeassistant/components/weatherkit/__init__.py | 1 + homeassistant/components/weatherkit/coordinator.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index 49158182696..4cbac2b32d8 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) coordinator = WeatherKitDataUpdateCoordinator( hass=hass, + config_entry=entry, client=WeatherKitApiClient( key_id=entry.data[CONF_KEY_ID], service_id=entry.data[CONF_SERVICE_ID], diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index 6438d7503db..6c7119d6fb0 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -33,6 +33,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: WeatherKitApiClient, ) -> None: """Initialize.""" @@ -40,6 +41,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=5), ) From 52fb99f9672dd43d3b8adebe299f7bfc7c8cf13d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:58:33 +0100 Subject: [PATCH 400/410] Explicitly pass in the config_entry in weatherflow_cloud coordinator (#137871) explicitly pass in the config_entry in coordinator --- .../components/weatherflow_cloud/__init__.py | 7 ++----- .../components/weatherflow_cloud/coordinator.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index 8dc26f9b9c6..94c65b7c0a1 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -15,10 +15,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WeatherFlowCloud from a config entry.""" - data_coordinator = WeatherFlowCloudDataUpdateCoordinator( - hass=hass, - api_token=entry.data[CONF_API_TOKEN], - ) + data_coordinator = WeatherFlowCloudDataUpdateCoordinator(hass, entry) await data_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 8b8a916262f..b6d2bfd5af2 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -6,6 +6,8 @@ from aiohttp import ClientResponseError from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,12 +20,17 @@ class WeatherFlowCloudDataUpdateCoordinator( ): """Class to manage fetching REST Based WeatherFlow Forecast data.""" - def __init__(self, hass: HomeAssistant, api_token: str) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global WeatherFlow forecast data updater.""" - self.weather_api = WeatherFlowRestAPI(api_token=api_token) + self.weather_api = WeatherFlowRestAPI( + api_token=config_entry.data[CONF_API_TOKEN] + ) super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) From dfa2c218e4afdf21ae332b67df1c77caff9ca507 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:00:36 +0100 Subject: [PATCH 401/410] Explicitly pass in the config_entry in verisure coordinator (#137879) explicitly pass in the config_entry in coordinator --- .../components/verisure/alarm_control_panel.py | 4 ++-- homeassistant/components/verisure/binary_sensor.py | 6 +++--- homeassistant/components/verisure/camera.py | 2 +- homeassistant/components/verisure/coordinator.py | 11 ++++++++--- homeassistant/components/verisure/lock.py | 4 ++-- homeassistant/components/verisure/sensor.py | 4 ++-- homeassistant/components/verisure/switch.py | 2 +- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5f34b587163..2b9ae7b60b6 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -49,14 +49,14 @@ class VerisureAlarm( name="Verisure Alarm", manufacturer="Verisure", model="VBox", - identifiers={(DOMAIN, self.coordinator.entry.data[CONF_GIID])}, + identifiers={(DOMAIN, self.coordinator.config_entry.data[CONF_GIID])}, configuration_url="https://mypages.verisure.com", ) @property def unique_id(self) -> str: """Return the unique ID for this entity.""" - return self.coordinator.entry.data[CONF_GIID] + return self.coordinator.config_entry.data[CONF_GIID] async def _async_set_arm_state( self, state: str, command_data: dict[str, str | dict[str, str]] diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 542ee3485ce..94a44550d47 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -62,7 +62,7 @@ class VerisureDoorWindowSensor( manufacturer="Verisure", model="Shock Sensor Detector", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -104,7 +104,7 @@ class VerisureEthernetStatus( @property def unique_id(self) -> str: """Return the unique ID for this entity.""" - return f"{self.coordinator.entry.data[CONF_GIID]}_ethernet" + return f"{self.coordinator.config_entry.data[CONF_GIID]}_ethernet" @property def device_info(self) -> DeviceInfo: @@ -113,7 +113,7 @@ class VerisureEthernetStatus( name="Verisure Alarm", manufacturer="Verisure", model="VBox", - identifiers={(DOMAIN, self.coordinator.entry.data[CONF_GIID])}, + identifiers={(DOMAIN, self.coordinator.config_entry.data[CONF_GIID])}, configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 70cd436d24c..7f49f917d83 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -75,7 +75,7 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) manufacturer="Verisure", model="SmartCam", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 930d862257b..5165ddc6d3d 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -25,10 +25,11 @@ from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER class VerisureDataUpdateCoordinator(DataUpdateCoordinator): """A Verisure Data Update Coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" self.imageseries: list[dict[str, str]] = [] - self.entry = entry self._overview: list[dict] = [] self.verisure = Verisure( @@ -40,7 +41,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): ) super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) async def async_login(self) -> bool: @@ -55,7 +60,7 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return False await self.hass.async_add_executor_job( - self.verisure.set_giid, self.entry.data[CONF_GIID] + self.verisure.set_giid, self.config_entry.data[CONF_GIID] ) return True diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 87f5c53880e..16c69ecf2e2 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -81,7 +81,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt manufacturer="Verisure", model="Lockguard Smartlock", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -109,7 +109,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def code_format(self) -> str: """Return the configured code format.""" - digits = self.coordinator.entry.options.get( + digits = self.coordinator.config_entry.options.get( CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS ) return f"^\\d{{{digits}}}$" diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 4f6e6b3d3c5..77a576caad8 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -72,7 +72,7 @@ class VerisureThermometer( manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -122,7 +122,7 @@ class VerisureHygrometer( manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index e0238097e01..838e0222087 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -57,7 +57,7 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch manufacturer="Verisure", model="SmartPlug", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) From 999badf6757cbd2e4a3568fc9ab4b8e621b76c1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:02:30 +0100 Subject: [PATCH 402/410] Explicitly pass in the config_entry in toon coordinator (#137899) explicitly pass in the config_entry in coordinator --- homeassistant/components/toon/__init__.py | 2 +- homeassistant/components/toon/coordinator.py | 45 +++++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 43c787b2301..1c56b780c0f 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -89,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - coordinator = ToonDataUpdateCoordinator(hass, entry=entry, session=session) + coordinator = ToonDataUpdateCoordinator(hass, entry, session) await coordinator.toon.activate_agreement( agreement_id=entry.data[CONF_AGREEMENT_ID] ) diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 586eca34959..894b4c91334 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -28,12 +28,13 @@ _LOGGER = logging.getLogger(__name__) class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): """Class to manage fetching Toon data from single endpoint.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session + self, hass: HomeAssistant, entry: ConfigEntry, session: OAuth2Session ) -> None: """Initialize global Toon data updater.""" self.session = session - self.entry = entry async def async_token_refresh() -> str: await session.async_ensure_token_valid() @@ -46,49 +47,55 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): ) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) async def register_webhook(self, event: Event | None = None) -> None: """Register a webhook with Toon to get live updates.""" - if CONF_WEBHOOK_ID not in self.entry.data: - data = {**self.entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} - self.hass.config_entries.async_update_entry(self.entry, data=data) + if CONF_WEBHOOK_ID not in self.config_entry.data: + data = {**self.config_entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} + self.hass.config_entries.async_update_entry(self.config_entry, data=data) if cloud.async_active_subscription(self.hass): - if CONF_CLOUDHOOK_URL not in self.entry.data: + if CONF_CLOUDHOOK_URL not in self.config_entry.data: try: webhook_url = await cloud.async_create_cloudhook( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) except cloud.CloudNotConnected: webhook_url = webhook.async_generate_url( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) else: - data = {**self.entry.data, CONF_CLOUDHOOK_URL: webhook_url} - self.hass.config_entries.async_update_entry(self.entry, data=data) + data = {**self.config_entry.data, CONF_CLOUDHOOK_URL: webhook_url} + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) else: - webhook_url = self.entry.data[CONF_CLOUDHOOK_URL] + webhook_url = self.config_entry.data[CONF_CLOUDHOOK_URL] else: webhook_url = webhook.async_generate_url( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) # Ensure the webhook is not registered already - webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(self.hass, self.config_entry.data[CONF_WEBHOOK_ID]) webhook_register( self.hass, DOMAIN, "Toon", - self.entry.data[CONF_WEBHOOK_ID], + self.config_entry.data[CONF_WEBHOOK_ID], self.handle_webhook, ) try: await self.toon.subscribe_webhook( - application_id=self.entry.entry_id, url=webhook_url + application_id=self.config_entry.entry_id, url=webhook_url ) _LOGGER.debug("Registered Toon webhook: %s", webhook_url) except ToonError as err: @@ -131,14 +138,14 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): async def unregister_webhook(self, event: Event | None = None) -> None: """Remove / Unregister webhook for toon.""" _LOGGER.debug( - "Unregistering Toon webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] + "Unregistering Toon webhook (%s)", self.config_entry.data[CONF_WEBHOOK_ID] ) try: - await self.toon.unsubscribe_webhook(self.entry.entry_id) + await self.toon.unsubscribe_webhook(self.config_entry.entry_id) except ToonError as err: _LOGGER.error("Failed unregistering Toon webhook - %s", err) - webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(self.hass, self.config_entry.data[CONF_WEBHOOK_ID]) async def _async_update_data(self) -> Status: """Fetch data from Toon.""" From f643f76f1f39041a31e8f3290c1ec1a87df50add Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:03:40 +0100 Subject: [PATCH 403/410] Explicitly pass in the config_entry in upnp coordinator (#137885) explicitly pass in the config_entry in coordinator --- homeassistant/components/upnp/__init__.py | 9 +++------ homeassistant/components/upnp/binary_sensor.py | 2 +- homeassistant/components/upnp/coordinator.py | 7 +++++++ homeassistant/components/upnp/sensor.py | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index aacb7538b61..757cad221b5 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -8,7 +8,6 @@ from datetime import timedelta from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -28,7 +27,7 @@ from .const import ( IDENTIFIER_SERIAL_NUMBER, LOGGER, ) -from .coordinator import UpnpDataUpdateCoordinator +from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator from .device import async_create_device, get_preferred_location NOTIFICATION_ID = "upnp_notification" @@ -37,9 +36,6 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.entry_id) @@ -176,6 +172,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) coordinator = UpnpDataUpdateCoordinator( hass, + config_entry=entry, device=device, device_entry=device_entry, update_interval=update_interval, @@ -193,7 +190,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index fb32946bf7d..1576cccac6a 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpConfigEntry, UpnpDataUpdateCoordinator from .const import LOGGER, WAN_STATUS +from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator from .entity import UpnpEntity, UpnpEntityDescription diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py index 37ff700bfe2..03e4c53f143 100644 --- a/homeassistant/components/upnp/coordinator.py +++ b/homeassistant/components/upnp/coordinator.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from async_upnp_client.exceptions import UpnpCommunicationError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER from .device import Device +type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] + class UpnpDataUpdateCoordinator( DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] ): """Define an object to update data from UPNP device.""" + config_entry: UpnpConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: UpnpConfigEntry, device: Device, device_entry: DeviceEntry, update_interval: timedelta, @@ -34,6 +40,7 @@ class UpnpDataUpdateCoordinator( super().__init__( hass, LOGGER, + config_entry=config_entry, name=device.name, update_interval=update_interval, ) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index aae2f8308c1..c0e77315f77 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -20,7 +20,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpConfigEntry from .const import ( BYTES_RECEIVED, BYTES_SENT, @@ -38,6 +37,7 @@ from .const import ( ROUTER_UPTIME, WAN_STATUS, ) +from .coordinator import UpnpConfigEntry from .entity import UpnpEntity, UpnpEntityDescription From 361933091c53626ec7a04f0c8577e9924a681a2c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:04:38 +0100 Subject: [PATCH 404/410] Explicitly pass in the config_entry in rova coordinator (#137966) explicitly pass in the config_entry in coordinator --- homeassistant/components/rova/__init__.py | 2 +- homeassistant/components/rova/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rova/__init__.py b/homeassistant/components/rova/__init__.py index 64f0e787a4b..ecde0578772 100644 --- a/homeassistant/components/rova/__init__.py +++ b/homeassistant/components/rova/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryError("Rova does not collect garbage in this area") - coordinator = RovaCoordinator(hass, api) + coordinator = RovaCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rova/coordinator.py b/homeassistant/components/rova/coordinator.py index ecd91cad823..a48048d32c3 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from rova.rova import Rova +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import get_time_zone @@ -16,11 +17,16 @@ EUROPE_AMSTERDAM_ZONE_INFO = get_time_zone("Europe/Amsterdam") class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Class to manage fetching Rova data.""" - def __init__(self, hass: HomeAssistant, api: Rova) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: Rova + ) -> None: """Initialize.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(hours=12), ) From 20707b94b55b162bfc5dd282efa48e1a29a6f8a7 Mon Sep 17 00:00:00 2001 From: Steve Sinchak Date: Sat, 8 Feb 2025 15:12:51 -0600 Subject: [PATCH 405/410] Improve emulated_hue logging to identify bad devices (#137919) * Improve emulated_hue logging to identify bad devices * Updated per @bdraco request --- homeassistant/components/emulated_hue/hue_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 464d2bcb7e7..9ccb8a64367 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -322,8 +322,10 @@ class HueOneLightStateView(HomeAssistantView): if hass_entity_id is None: _LOGGER.error( - "Unknown entity number: %s not found in emulated_hue_ids.json", + "Unknown entity number: %s not found in emulated_hue_ids.json, " + "state request from %s", entity_id, + request.remote, ) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) From bdcf2a1e56b9d6de32f214d2a9314ede31d0d88c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:18:55 +0100 Subject: [PATCH 406/410] Explicitly pass in the config_entry in ridwell coordinator (#137973) explicitly pass in the config_entry in coordinator --- homeassistant/components/ridwell/__init__.py | 2 +- homeassistant/components/ridwell/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 71e80086833..9c9104258a8 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -17,7 +17,7 @@ PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ridwell from a config entry.""" - coordinator = RidwellDataUpdateCoordinator(hass, name=entry.title) + coordinator = RidwellDataUpdateCoordinator(hass, entry) await coordinator.async_initialize() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index 28190522c76..336a71bc67f 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -29,7 +29,7 @@ class RidwellDataUpdateCoordinator( config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, *, name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: @@ -37,7 +37,13 @@ class RidwellDataUpdateCoordinator( self.dashboard_url = "" self.user_id = "" - super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" From 50c15f305606c3f2a35b3267e9334c4293b91382 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 8 Feb 2025 16:29:18 -0500 Subject: [PATCH 407/410] Bump pydrawise to 2025.2.0 (#137961) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index de45eb061d5..73423882e4a 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.1.0"] + "requirements": ["pydrawise==2025.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2934a232c6..27b6b091960 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1900,7 +1900,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.1.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83b20bbfb94..7b6fd7f011f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1550,7 +1550,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.1.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 17569d8186340d0988d3d65f3dd522f588e3e663 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:39:11 +0100 Subject: [PATCH 408/410] Explicitly pass in the config_entry in wallbox coordinator (#137874) explicitly pass in the config_entry in coordinator --- homeassistant/components/wallbox/__init__.py | 8 ++------ homeassistant/components/wallbox/coordinator.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index b2f8ac7fd5d..fc8c6e00e84 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] @@ -27,11 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidAuth as ex: raise ConfigEntryAuthFailed from ex - wallbox_coordinator = WallboxCoordinator( - entry.data[CONF_STATION], - wallbox, - hass, - ) + wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox) await wallbox_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 99c565d9c0c..4f20f5c406d 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, Concatenate import requests from wallbox import Wallbox +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -28,6 +29,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, CHARGER_STATUS_ID_KEY, CODE_KEY, + CONF_STATION, DOMAIN, UPDATE_INTERVAL, ChargerStatus, @@ -107,14 +109,19 @@ async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None: class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" - def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, wallbox: Wallbox + ) -> None: """Initialize.""" - self._station = station + self._station = config_entry.data[CONF_STATION] self._wallbox = wallbox super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) From 7ec7badef637d109397bfe7be1d0d3a044704976 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:39:42 +0100 Subject: [PATCH 409/410] Explicitly pass in the config_entry in volvooncall coordinator (#137875) explicitly pass in the config_entry in coordinator --- homeassistant/components/volvooncall/__init__.py | 2 +- homeassistant/components/volvooncall/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 9fc07dd92b0..1a53f9a5dc4 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: volvo_data = VolvoData(hass, connection, entry) - coordinator = VolvoUpdateCoordinator(hass, volvo_data) + coordinator = VolvoUpdateCoordinator(hass, entry, volvo_data) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py index 5ac6a58acb0..2c3e2ba365f 100644 --- a/homeassistant/components/volvooncall/coordinator.py +++ b/homeassistant/components/volvooncall/coordinator.py @@ -3,6 +3,7 @@ import asyncio import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,12 +16,17 @@ _LOGGER = logging.getLogger(__name__) class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): """Volvo coordinator.""" - def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, volvo_data: VolvoData + ) -> None: """Initialize the data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="volvooncall", update_interval=DEFAULT_UPDATE_INTERVAL, ) From 54c4ee7838abb06a5629cc03567333251453c489 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:40:14 +0100 Subject: [PATCH 410/410] Explicitly pass in the config_entry in vizio coordinator (#137876) explicitly pass in the config_entry in coordinator --- homeassistant/components/vizio/__init__.py | 2 +- homeassistant/components/vizio/coordinator.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 4af42d76b62..27a7fa2cd97 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN) - coordinator = VizioAppsDataUpdateCoordinator(hass, store) + coordinator = VizioAppsDataUpdateCoordinator(hass, entry, store) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index a7ca7d7f9ed..0f95c8a53b7 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -9,6 +9,7 @@ from typing import Any from pyvizio.const import APPS from pyvizio.util import gen_apps_list_from_url +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import Store @@ -22,11 +23,19 @@ _LOGGER = logging.getLogger(__name__) class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + store: Store[list[dict[str, Any]]], + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(days=1), )